Merge pull request #668 from bigcapitalhq/stripe-integrate

feat: Onboard accounts to Stripe Connect
This commit is contained in:
Ahmed Bouhuolia
2024-09-24 14:12:39 +02:00
committed by GitHub
133 changed files with 5547 additions and 90 deletions

View File

@@ -92,4 +92,8 @@ S3_BUCKET=
# PostHog
POSTHOG_API_KEY=
POSTHOG_HOST=
POSTHOG_HOST=
# Stripe Payment
STRIPE_PAYMENT_SECRET_KEY=
STRIPE_PAYMENT_PUBLISHABLE_KEY=

View File

@@ -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"

View File

@@ -0,0 +1,179 @@
import { Service, Inject } from 'typedi';
import { Request, Response, Router, NextFunction } from 'express';
import { body, param } from 'express-validator';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import BaseController from '@/api/controllers/BaseController';
import { PaymentServicesApplication } from '@/services/PaymentServices/PaymentServicesApplication';
@Service()
export class PaymentServicesController extends BaseController {
@Inject()
private paymentServicesApp: PaymentServicesApplication;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/',
asyncMiddleware(this.getPaymentServicesSpecificInvoice.bind(this))
);
router.get('/state', this.getPaymentMethodsState.bind(this));
router.get('/:paymentServiceId', this.getPaymentService.bind(this));
router.post(
'/:paymentMethodId',
[
param('paymentMethodId').exists(),
body('name').optional().isString(),
body('options.bank_account_id').optional().isNumeric(),
body('options.clearing_account_id').optional().isNumeric(),
],
this.validationResult,
asyncMiddleware(this.updatePaymentMethod.bind(this))
);
router.delete(
'/:paymentMethodId',
[param('paymentMethodId').exists()],
this.validationResult,
this.deletePaymentMethod.bind(this)
);
return router;
}
/**
* Retrieve accounts types list.
* @param {Request} req - Request.
* @param {Response} res - Response.
* @return {Promise<Response | void>}
*/
private async getPaymentServicesSpecificInvoice(
req: Request<{ invoiceId: number }>,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
try {
const paymentServices =
await this.paymentServicesApp.getPaymentServicesForInvoice(tenantId);
return res.status(200).send({ paymentServices });
} catch (error) {
next(error);
}
}
/**
* Retrieves a specific payment service.
* @param {Request} req - Request.
* @param {Response} res - Response.
* @param {NextFunction} next - Next function.
* @return {Promise<Response | void>}
*/
private async getPaymentService(
req: Request<{ paymentServiceId: number }>,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { paymentServiceId } = req.params;
try {
const paymentService = await this.paymentServicesApp.getPaymentService(
tenantId,
paymentServiceId
);
return res.status(200).send({ data: paymentService });
} catch (error) {
next(error);
}
}
/**
* Edits the given payment method settings.
* @param {Request} req - Request.
* @param {Response} res - Response.
* @return {Promise<Response | void>}
*/
private async updatePaymentMethod(
req: Request<{ paymentMethodId: number }>,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { paymentMethodId } = req.params;
const updatePaymentMethodDTO = this.matchedBodyData(req);
try {
await this.paymentServicesApp.editPaymentMethod(
tenantId,
paymentMethodId,
updatePaymentMethodDTO
);
return res.status(200).send({
id: paymentMethodId,
message: 'The given payment method has been updated.',
});
} catch (error) {
next(error);
}
}
/**
* Retrieves the payment state providing state.
* @param {Request} req - Request.
* @param {Response} res - Response.
* @param {NextFunction} next - Next function.
* @return {Promise<Response | void>}
*/
private async getPaymentMethodsState(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
try {
const paymentMethodsState =
await this.paymentServicesApp.getPaymentMethodsState(tenantId);
return res.status(200).send({ data: paymentMethodsState });
} catch (error) {
next(error);
}
}
/**
* Deletes the given payment method.
* @param {Request<{ paymentMethodId: number }>} req - Request.
* @param {Response} res - Response.
* @param {NextFunction} next - Next function.
* @return {Promise<Response | void>}
*/
private async deletePaymentMethod(
req: Request<{ paymentMethodId: number }>,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { paymentMethodId } = req.params;
try {
await this.paymentServicesApp.deletePaymentMethod(
tenantId,
paymentMethodId
);
return res.status(204).send({
id: paymentMethodId,
message: 'The payment method has been deleted.',
});
} catch (error) {
next(error);
}
}
}

View File

@@ -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(),
];
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,153 @@
import { NextFunction, Request, Response, Router } from 'express';
import { body } from 'express-validator';
import { Service, Inject } from 'typedi';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import { StripePaymentApplication } from '@/services/StripePayment/StripePaymentApplication';
import BaseController from '../BaseController';
@Service()
export class StripeIntegrationController extends BaseController {
@Inject()
private stripePaymentApp: StripePaymentApplication;
public router() {
const router = Router();
router.get('/link', this.getStripeConnectLink.bind(this));
router.post(
'/callback',
[body('code').exists()],
this.validationResult,
this.exchangeOAuth.bind(this)
);
router.post('/account', asyncMiddleware(this.createAccount.bind(this)));
router.post(
'/account_link',
[body('stripe_account_id').exists()],
this.validationResult,
asyncMiddleware(this.createAccountLink.bind(this))
);
router.post(
'/:linkId/create_checkout_session',
this.createCheckoutSession.bind(this)
);
return router;
}
/**
* Retrieves Stripe OAuth2 connect link.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|void>}
*/
public async getStripeConnectLink(
req: Request,
res: Response,
next: NextFunction
) {
try {
const authorizationUri = this.stripePaymentApp.getStripeConnectLink();
return res.status(200).send({ url: authorizationUri });
} catch (error) {
next(error);
}
}
/**
* Exchanges the given Stripe authorization code to Stripe user id and access token.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<void>}
*/
public async exchangeOAuth(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const { code } = this.matchedBodyData(req);
try {
await this.stripePaymentApp.exchangeStripeOAuthToken(tenantId, code);
return res.status(200).send({});
} catch (error) {
next(error);
}
}
/**
* Creates a Stripe checkout session for the given payment link id.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|void>}
*/
public async createCheckoutSession(
req: Request<{ linkId: number }>,
res: Response,
next: NextFunction
) {
const { linkId } = req.params;
const { tenantId } = req;
try {
const session =
await this.stripePaymentApp.createSaleInvoiceCheckoutSession(
tenantId,
linkId
);
return res.status(200).send(session);
} catch (error) {
next(error);
}
}
/**
* Creates a new Stripe account.
* @param {Request} req - The Express request object.
* @param {Response} res - The Express response object.
* @param {NextFunction} next - The Express next middleware function.
* @returns {Promise<void>}
*/
public async createAccount(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
try {
const accountId = await this.stripePaymentApp.createStripeAccount(
tenantId
);
return res.status(201).json({
accountId,
message: 'The Stripe account has been created successfully.',
});
} catch (error) {
next(error);
}
}
/**
* Creates a new Stripe account session.
* @param {Request} req - The Express request object.
* @param {Response} res - The Express response object.
* @param {NextFunction} next - The Express next middleware function.
* @returns {Promise<void>}
*/
public async createAccountLink(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { stripeAccountId } = this.matchedBodyData(req);
try {
const clientSecret = await this.stripePaymentApp.createAccountLink(
tenantId,
stripeAccountId
);
return res.status(200).json({ clientSecret });
} catch (error) {
next(error);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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 || '',
},
};

View File

@@ -0,0 +1,19 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.table('payment_receives', (table) => {
table.string('stripe_pintent_id').nullable();
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.table('payment_receives', (table) => {
table.dropColumn('stripe_pintent_id');
});
};

View File

@@ -0,0 +1,25 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('payment_integrations', (table) => {
table.increments('id');
table.string('service');
table.string('name');
table.string('slug');
table.boolean('payment_enabled').defaultTo(false);
table.boolean('payout_enabled').defaultTo(false);
table.string('account_id');
table.json('options');
table.timestamps();
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('payment_integrations');
};

View File

@@ -0,0 +1,27 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('transactions_payment_methods', (table) => {
table.increments('id');
table.integer('reference_id').unsigned();
table.string('reference_type');
table
.integer('payment_integration_id')
.unsigned()
.index()
.references('id')
.inTable('payment_integrations');
table.boolean('enable').defaultTo(false);
table.json('options').nullable();
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('transactions_payment_methods');
};

View File

@@ -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',

View File

@@ -5,6 +5,34 @@ import { IDynamicListFilter } from '@/interfaces/DynamicFilter';
import { IItemEntry, IItemEntryDTO } from './ItemEntry';
import { AttachmentLinkDTO } from './Attachments';
export interface PaymentIntegrationTransactionLink {
id: number;
enable: true;
paymentIntegrationId: number;
referenceType: string;
referenceId: number;
}
export interface PaymentIntegrationTransactionLinkEventPayload {
tenantId: number;
enable: true;
paymentIntegrationId: number;
referenceType: string;
referenceId: number;
saleInvoiceId: number;
trx?: Knex.Transaction
}
export interface PaymentIntegrationTransactionLinkDeleteEventPayload {
tenantId: number;
enable: true;
paymentIntegrationId: number;
referenceType: string;
referenceId: number;
oldSaleInvoiceId: number;
trx?: Knex.Transaction
}
export interface ISaleInvoice {
id: number;
amount: number;
@@ -50,6 +78,8 @@ export interface ISaleInvoice {
invoiceMessage: string;
pdfTemplateId?: number;
paymentMethods?: Array<PaymentIntegrationTransactionLink>;
}
export interface ISaleInvoiceDTO {
@@ -136,9 +166,15 @@ export interface ISaleInvoiceEditingPayload {
export interface ISaleInvoiceDeletePayload {
tenantId: number;
saleInvoice: ISaleInvoice;
oldSaleInvoice: ISaleInvoice;
saleInvoiceId: number;
trx: Knex.Transaction;
}
export interface ISaleInvoiceDeletingPayload {
tenantId: number;
oldSaleInvoice: ISaleInvoice;
saleInvoiceId: number;
trx: Knex.Transaction;
}
export interface ISaleInvoiceDeletedPayload {
@@ -223,7 +259,6 @@ export interface ISaleInvoiceMailSent {
messageOptions: SendInvoiceMailDTO;
}
// Invoice Pdf Document
export interface InvoicePdfLine {
item: string;
@@ -241,9 +276,9 @@ export interface InvoicePdfTax {
export interface InvoicePdfTemplateAttributes {
primaryColor: string;
secondaryColor: string;
companyName: string;
showCompanyLogo: boolean;
companyLogo: string;
@@ -301,4 +336,4 @@ export interface InvoicePdfTemplateAttributes {
billedToAddress: string[];
billedFromAddres: string[];
}
}

View File

@@ -0,0 +1,20 @@
export interface StripePaymentLinkCreatedEventPayload {
tenantId: number;
paymentLinkId: string;
saleInvoiceId: number;
stripeIntegrationId: number;
}
export interface StripeCheckoutSessionCompletedEventPayload {
event: any;
}
export interface StripeInvoiceCheckoutSessionPOJO {
sessionId: string;
publishableKey: string;
redirectTo: string;
}
export interface StripeWebhookEventPayload {
event: any;
}

View File

@@ -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
];
};

View File

@@ -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));
};

View File

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

View File

@@ -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',
}
},
};
}

View File

@@ -0,0 +1,46 @@
import TenantModel from 'models/TenantModel';
export class TransactionPaymentServiceEntry extends TenantModel {
/**
* Table name
*/
static get tableName() {
return 'transactions_payment_methods';
}
/**
* Json schema of the model.
*/
static get jsonSchema() {
return {
type: 'object',
required: ['paymentIntegrationId'],
properties: {
id: { type: 'integer' },
referenceId: { type: 'integer' },
referenceType: { type: 'string' },
paymentIntegrationId: { type: 'integer' },
enable: { type: 'boolean' },
options: { type: 'object' },
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const { PaymentIntegration } = require('./PaymentIntegration');
return {
paymentIntegration: {
relation: TenantModel.BelongsToOneRelation,
modelClass: PaymentIntegration,
join: {
from: 'transactions_payment_methods.paymentIntegrationId',
to: 'payment_integrations.id',
},
},
};
}
}

View File

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

View File

@@ -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
);
}

View File

@@ -0,0 +1,54 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import HasTenancyService from '../Tenancy/TenancyService';
import UnitOfWork from '../UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
@Service()
export class DeletePaymentMethodService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* Deletes the given payment integration.
* @param {number} tenantId
* @param {number} paymentIntegrationId
* @returns {Promise<void>}
*/
public async deletePaymentMethod(
tenantId: number,
paymentIntegrationId: number
): Promise<void> {
const { PaymentIntegration, TransactionPaymentServiceEntry } =
this.tenancy.models(tenantId);
const paymentIntegration = await PaymentIntegration.query()
.findById(paymentIntegrationId)
.throwIfNotFound();
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Delete payment methods links.
await TransactionPaymentServiceEntry.query(trx)
.where('paymentIntegrationId', paymentIntegrationId)
.delete();
// Delete the payment integration.
await PaymentIntegration.query(trx)
.findById(paymentIntegrationId)
.delete();
// Triggers `onPaymentMethodDeleted` event.
await this.eventPublisher.emitAsync(events.paymentMethod.onDeleted, {
tenantId,
paymentIntegrationId,
});
});
}
}

View File

@@ -0,0 +1,60 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import UnitOfWork from '../UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { EditPaymentMethodDTO } from './types';
import events from '@/subscribers/events';
@Service()
export class EditPaymentMethodService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* Edits the given payment method.
* @param {number} tenantId
* @param {number} paymentIntegrationId
* @param {EditPaymentMethodDTO} editPaymentMethodDTO
* @returns {Promise<void>}
*/
async editPaymentMethod(
tenantId: number,
paymentIntegrationId: number,
editPaymentMethodDTO: EditPaymentMethodDTO
): Promise<void> {
const { PaymentIntegration } = this.tenancy.models(tenantId);
const paymentMethod = await PaymentIntegration.query()
.findById(paymentIntegrationId)
.throwIfNotFound();
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onPaymentMethodEditing` event.
await this.eventPublisher.emitAsync(events.paymentMethod.onEditing, {
tenantId,
paymentIntegrationId,
editPaymentMethodDTO,
trx,
});
await PaymentIntegration.query(trx)
.findById(paymentIntegrationId)
.patch({
...editPaymentMethodDTO,
});
// Triggers `onPaymentMethodEdited` event.
await this.eventPublisher.emitAsync(events.paymentMethod.onEdited, {
tenantId,
paymentIntegrationId,
editPaymentMethodDTO,
trx,
});
});
}
}

View File

@@ -0,0 +1,62 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { GetPaymentMethodsPOJO } from './types';
import config from '@/config';
import { isStripePaymentConfigured } from './utils';
import { GetStripeAuthorizationLinkService } from '../StripePayment/GetStripeAuthorizationLink';
@Service()
export class GetPaymentMethodsStateService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private getStripeAuthorizationLinkService: GetStripeAuthorizationLinkService;
/**
* Retrieves the payment state provising state.
* @param {number} tenantId
* @returns {Promise<GetPaymentMethodsPOJO>}
*/
public async getPaymentMethodsState(
tenantId: number
): Promise<GetPaymentMethodsPOJO> {
const { PaymentIntegration } = this.tenancy.models(tenantId);
const stripePayment = await PaymentIntegration.query()
.orderBy('createdAt', 'ASC')
.findOne({
service: 'Stripe',
});
const isStripeAccountCreated = !!stripePayment;
const isStripePaymentEnabled = stripePayment?.paymentEnabled;
const isStripePayoutEnabled = stripePayment?.payoutEnabled;
const isStripeEnabled = stripePayment?.fullEnabled;
const stripePaymentMethodId = stripePayment?.id || null;
const stripeAccountId = stripePayment?.accountId || null;
const stripePublishableKey = config.stripePayment.publishableKey;
const stripeCurrencies = ['USD', 'EUR'];
const stripeRedirectUrl = 'https://your-stripe-redirect-url.com';
const isStripeServerConfigured = isStripePaymentConfigured();
const stripeAuthLink =
this.getStripeAuthorizationLinkService.getStripeAuthLink();
const paymentMethodPOJO: GetPaymentMethodsPOJO = {
stripe: {
isStripeAccountCreated,
isStripePaymentEnabled,
isStripePayoutEnabled,
isStripeEnabled,
isStripeServerConfigured,
stripeAccountId,
stripePaymentMethodId,
stripePublishableKey,
stripeCurrencies,
stripeAuthLink,
stripeRedirectUrl,
},
};
return paymentMethodPOJO;
}
}

View File

@@ -0,0 +1,27 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { GetPaymentMethodsPOJO } from './types';
@Service()
export class GetPaymentMethodService {
@Inject()
private tenancy: HasTenancyService;
/**
* Retrieves the payment state provising state.
* @param {number} tenantId
* @returns {Promise<GetPaymentMethodsPOJO>}
*/
public async getPaymentMethod(
tenantId: number,
paymentServiceId: number
): Promise<GetPaymentMethodsPOJO> {
const { PaymentIntegration } = this.tenancy.models(tenantId);
const stripePayment = await PaymentIntegration.query()
.findById(paymentServiceId)
.throwIfNotFound();
return stripePayment;
}
}

View File

@@ -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()
);
}
}

View File

@@ -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';
}
}

View File

@@ -0,0 +1,95 @@
import { Service, Inject } from 'typedi';
import { GetPaymentServicesSpecificInvoice } from './GetPaymentServicesSpecificInvoice';
import { DeletePaymentMethodService } from './DeletePaymentMethodService';
import { EditPaymentMethodService } from './EditPaymentMethodService';
import { EditPaymentMethodDTO, GetPaymentMethodsPOJO } from './types';
import { GetPaymentMethodsStateService } from './GetPaymentMethodsState';
import { GetPaymentMethodService } from './GetPaymentService';
@Service()
export class PaymentServicesApplication {
@Inject()
private getPaymentServicesSpecificInvoice: GetPaymentServicesSpecificInvoice;
@Inject()
private deletePaymentMethodService: DeletePaymentMethodService;
@Inject()
private editPaymentMethodService: EditPaymentMethodService;
@Inject()
private getPaymentMethodsStateService: GetPaymentMethodsStateService;
@Inject()
private getPaymentMethodService: GetPaymentMethodService;
/**
* Retrieves the payment services for a specific invoice.
* @param {number} tenantId - The ID of the tenant.
* @param {number} invoiceId - The ID of the invoice.
* @returns {Promise<any>} The payment services for the specified invoice.
*/
public async getPaymentServicesForInvoice(tenantId: number): Promise<any> {
return this.getPaymentServicesSpecificInvoice.getPaymentServicesInvoice(
tenantId
);
}
/**
* Retrieves specific payment service details.
* @param {number} tenantId - Tennat id.
* @param {number} paymentServiceId - Payment service id.
*/
public async getPaymentService(tenantId: number, paymentServiceId: number) {
return this.getPaymentMethodService.getPaymentMethod(
tenantId,
paymentServiceId
);
}
/**
* Deletes the given payment method.
* @param {number} tenantId
* @param {number} paymentIntegrationId
* @returns {Promise<void>}
*/
public async deletePaymentMethod(
tenantId: number,
paymentIntegrationId: number
): Promise<void> {
return this.deletePaymentMethodService.deletePaymentMethod(
tenantId,
paymentIntegrationId
);
}
/**
* Edits the given payment method.
* @param {number} tenantId
* @param {number} paymentIntegrationId
* @param {EditPaymentMethodDTO} editPaymentMethodDTO
* @returns {Promise<void>}
*/
public async editPaymentMethod(
tenantId: number,
paymentIntegrationId: number,
editPaymentMethodDTO: EditPaymentMethodDTO
): Promise<void> {
return this.editPaymentMethodService.editPaymentMethod(
tenantId,
paymentIntegrationId,
editPaymentMethodDTO
);
}
/**
* Retrieves the payment state providing state.
* @param {number} tenantId
* @returns {Promise<GetPaymentMethodsPOJO>}
*/
public async getPaymentMethodsState(
tenantId: number
): Promise<GetPaymentMethodsPOJO> {
return this.getPaymentMethodsStateService.getPaymentMethodsState(tenantId);
}
}

View File

@@ -0,0 +1,33 @@
export interface EditPaymentMethodDTO {
name?: string;
options?: {
bankAccountId?: number; // bank account.
clearningAccountId?: number; // current liability.
showVisa?: boolean;
showMasterCard?: boolean;
showDiscover?: boolean;
showAmer?: boolean;
showJcb?: boolean;
showDiners?: boolean;
};
}
export interface GetPaymentMethodsPOJO {
stripe: {
isStripeAccountCreated: boolean;
isStripePaymentEnabled: boolean;
isStripePayoutEnabled: boolean;
isStripeEnabled: boolean;
isStripeServerConfigured: boolean;
stripeAccountId: string | null;
stripePaymentMethodId: number | null;
stripePublishableKey: string | null;
stripeAuthLink: string;
stripeCurrencies: Array<string>;
stripeRedirectUrl: string | null;
};
}

View File

@@ -0,0 +1,9 @@
import config from '@/config';
export const isStripePaymentConfigured = () => {
return (
config.stripePayment.secretKey &&
config.stripePayment.publishableKey &&
config.stripePayment.webhooksSecret
);
};

View File

@@ -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(

View File

@@ -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);
}
}

View File

@@ -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()
);
});
}
}

View File

@@ -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()
);
}
}

View File

@@ -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 ['*'];
};
}

View File

@@ -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);

View File

@@ -1,3 +1,5 @@
import config from '@/config';
export const DEFAULT_INVOICE_MAIL_SUBJECT =
'Invoice {InvoiceNumber} from {CompanyName}';
export const DEFAULT_INVOICE_MAIL_CONTENT = `
@@ -30,6 +32,8 @@ Amount : <strong>{InvoiceAmount}</strong></p>
</p>
`;
export const PUBLIC_PAYMENT_LINK = `${config.baseURL}/payment/{PAYMENT_LINK_ID}`;
export const ERRORS = {
INVOICE_NUMBER_NOT_UNIQUE: 'INVOICE_NUMBER_NOT_UNIQUE',
SALE_INVOICE_NOT_FOUND: 'SALE_INVOICE_NOT_FOUND',

View File

@@ -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
);
}
);
};
}

View File

@@ -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 {

View File

@@ -0,0 +1,100 @@
import config from '@/config';
import { StripePaymentService } from './StripePaymentService';
import HasTenancyService from '../Tenancy/TenancyService';
import { ISaleInvoice } from '@/interfaces';
import { Inject, Service } from 'typedi';
import { StripeInvoiceCheckoutSessionPOJO } from '@/interfaces/StripePayment';
import { PaymentLink } from '@/system/models';
const origin = 'http://localhost';
@Service()
export class CreateInvoiceCheckoutSession {
@Inject()
private stripePaymentService: StripePaymentService;
@Inject()
private tenancy: HasTenancyService;
/**
* Creates a new Stripe checkout session from the given sale invoice.
* @param {number} tenantId
* @param {number} saleInvoiceId - Sale invoice id.
* @returns {Promise<StripeInvoiceCheckoutSessionPOJO>}
*/
async createInvoiceCheckoutSession(
tenantId: number,
publicPaymentLinkId: number
): Promise<StripeInvoiceCheckoutSessionPOJO> {
const { SaleInvoice } = this.tenancy.models(tenantId);
// Retrieves the payment link from the given id.
const paymentLink = await PaymentLink.query()
.findOne('linkId', publicPaymentLinkId)
.where('resourceType', 'SaleInvoice')
.throwIfNotFound();
// Retrieves the invoice from associated payment link.
const invoice = await SaleInvoice.query()
.findById(paymentLink.resourceId)
.withGraphFetched('paymentMethods')
.throwIfNotFound();
// It will be only one Stripe payment method associated to the invoice.
const stripePaymentMethod = invoice.paymentMethods?.find(
(method) => method.paymentIntegration?.service === 'Stripe'
);
const stripeAccountId = stripePaymentMethod?.paymentIntegration?.accountId;
const paymentIntegrationId = stripePaymentMethod?.paymentIntegration?.id;
// Creates checkout session for the given invoice.
const session = await this.createCheckoutSession(invoice, stripeAccountId, {
tenantId,
paymentLinkId: paymentLink.id,
});
return {
sessionId: session.id,
publishableKey: config.stripePayment.publishableKey,
redirectTo: session.url,
};
}
/**
* Creates a new Stripe checkout session for the given sale invoice.
* @param {ISaleInvoice} invoice - The sale invoice for which the checkout session is created.
* @param {string} stripeAccountId - The Stripe account ID associated with the payment method.
* @returns {Promise<any>} - The created Stripe checkout session.
*/
private createCheckoutSession(
invoice: ISaleInvoice,
stripeAccountId: string,
metadata?: Record<string, any>
) {
return this.stripePaymentService.stripe.checkout.sessions.create(
{
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: invoice.currencyCode,
product_data: {
name: invoice.invoiceNo,
},
unit_amount: invoice.total * 100, // Amount in cents
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${origin}/success`,
cancel_url: `${origin}/cancel`,
metadata: {
saleInvoiceId: invoice.id,
resource: 'SaleInvoice',
...metadata,
},
},
{ stripeAccount: stripeAccountId }
);
}
}

View File

@@ -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 }],
});
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,55 @@
import { Inject, Service } from 'typedi';
import { StripePaymentService } from '@/services/StripePayment/StripePaymentService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { CreateStripeAccountDTO } from './types';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
@Service()
export class CreateStripeAccountService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private stripePaymentService: StripePaymentService;
@Inject()
private eventPublisher: EventPublisher;
/**
* Creates a new Stripe account.
* @param {number} tenantI
* @param {CreateStripeAccountDTO} stripeAccountDTO
* @returns {Promise<string>}
*/
async createStripeAccount(
tenantId: number,
stripeAccountDTO?: CreateStripeAccountDTO
): Promise<string> {
const { PaymentIntegration } = this.tenancy.models(tenantId);
const stripeAccount = await this.stripePaymentService.createAccount();
const stripeAccountId = stripeAccount.id;
const parsedStripeAccountDTO = {
name: 'Stripe',
...stripeAccountDTO,
};
// Stores the details of the Stripe account.
await PaymentIntegration.query().insert({
name: parsedStripeAccountDTO.name,
accountId: stripeAccountId,
active: false, // Active will turn true after onboarding.
service: 'Stripe',
});
// Triggers `onStripeIntegrationAccountCreated` event.
await this.eventPublisher.emitAsync(
events.stripeIntegration.onAccountCreated,
{
tenantId,
stripeAccountDTO,
stripeAccountId,
}
);
return stripeAccountId;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,89 @@
import { Inject } from 'typedi';
import { CreateInvoiceCheckoutSession } from './CreateInvoiceCheckoutSession';
import { StripeInvoiceCheckoutSessionPOJO } from '@/interfaces/StripePayment';
import { CreateStripeAccountService } from './CreateStripeAccountService';
import { CreateStripeAccountLinkService } from './CreateStripeAccountLink';
import { CreateStripeAccountDTO } from './types';
import { ExchangeStripeOAuthTokenService } from './ExchangeStripeOauthToken';
import { GetStripeAuthorizationLinkService } from './GetStripeAuthorizationLink';
export class StripePaymentApplication {
@Inject()
private createStripeAccountService: CreateStripeAccountService;
@Inject()
private createStripeAccountLinkService: CreateStripeAccountLinkService;
@Inject()
private createInvoiceCheckoutSessionService: CreateInvoiceCheckoutSession;
@Inject()
private exchangeStripeOAuthTokenService: ExchangeStripeOAuthTokenService;
@Inject()
private getStripeConnectLinkService: GetStripeAuthorizationLinkService;
/**
* Creates a new Stripe account for Bigcapital.
* @param {number} tenantId
* @param {number} createStripeAccountDTO
*/
public createStripeAccount(
tenantId: number,
createStripeAccountDTO: CreateStripeAccountDTO = {}
) {
return this.createStripeAccountService.createStripeAccount(
tenantId,
createStripeAccountDTO
);
}
/**
* Creates a new Stripe account link of the given Stripe accoun..
* @param {number} tenantId
* @param {string} stripeAccountId
* @returns {}
*/
public createAccountLink(tenantId: number, stripeAccountId: string) {
return this.createStripeAccountLinkService.createAccountLink(
tenantId,
stripeAccountId
);
}
/**
* Creates the Stripe checkout session from the given sale invoice.
* @param {number} tenantId
* @param {string} paymentLinkId
* @returns {Promise<StripeInvoiceCheckoutSessionPOJO>}
*/
public createSaleInvoiceCheckoutSession(
tenantId: number,
paymentLinkId: number
): Promise<StripeInvoiceCheckoutSessionPOJO> {
return this.createInvoiceCheckoutSessionService.createInvoiceCheckoutSession(
tenantId,
paymentLinkId
);
}
/**
* Retrieves Stripe OAuth2 connect link.
* @returns {string}
*/
public getStripeConnectLink() {
return this.getStripeConnectLinkService.getStripeAuthLink();
}
/**
* Exchanges the given Stripe authorization code to Stripe user id and access token.
* @param {string} authorizationCode
* @returns
*/
public exchangeStripeOAuthToken(tenantId: number, authorizationCode: string) {
return this.exchangeStripeOAuthTokenService.excahngeStripeOAuthToken(
tenantId,
authorizationCode
);
}
}

View File

@@ -0,0 +1,75 @@
import { Service } from 'typedi';
import stripe from 'stripe';
import config from '@/config';
const origin = 'https://cfdf-102-164-97-88.ngrok-free.app';
@Service()
export class StripePaymentService {
public stripe: stripe;
constructor() {
this.stripe = new stripe(config.stripePayment.secretKey, {
apiVersion: '2024-06-20',
});
}
/**
*
* @param {number} accountId
* @returns {Promise<string>}
*/
public async createAccountSession(accountId: string): Promise<string> {
try {
const accountSession = await this.stripe.accountSessions.create({
account: accountId,
components: {
account_onboarding: { enabled: true },
},
});
return accountSession.client_secret;
} catch (error) {
throw new Error(
'An error occurred when calling the Stripe API to create an account session'
);
}
}
/**
*
* @param {number} accountId
* @returns
*/
public async createAccountLink(accountId: string) {
try {
const accountLink = await this.stripe.accountLinks.create({
account: accountId,
return_url: `${origin}/return/${accountId}`,
refresh_url: `${origin}/refresh/${accountId}`,
type: 'account_onboarding',
});
return accountLink;
} catch (error) {
throw new Error(
'An error occurred when calling the Stripe API to create an account link:'
);
}
}
/**
*
* @returns {Promise<string>}
*/
public async createAccount(): Promise<string> {
try {
const account = await this.stripe.accounts.create({
type: 'standard',
});
return account;
} catch (error) {
throw new Error(
'An error occurred when calling the Stripe API to create an account'
);
}
}
}

View File

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

View File

@@ -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,
});
}
}
}

View File

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

View File

@@ -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
);
};

View File

@@ -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'
}
};

View File

@@ -0,0 +1,20 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('stripe_accounts', (table) => {
table.increments('id').primary();
table.string('stripe_account_id').notNullable();
table.string('tenant_id').notNullable();
table.timestamps(true, true); // Adds created_at and updated_at columns
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('stripe_accounts');
};

View File

@@ -0,0 +1,24 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('payment_links', (table) => {
table.increments('id');
table.integer('tenant_id');
table.integer('resource_id');
table.text('resource_type');
table.string('linkId');
table.string('publicity');
table.datetime('expiry_at');
table.timestamps();
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('payment_links');
};

View File

@@ -0,0 +1,26 @@
import { Model } from 'objection';
export class PaymentLink extends Model {
static get tableName() {
return 'payment_links';
}
/**
* Timestamps columns.
* @returns {string[]}
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
public tenantId!: number;
public resourceId!: number;
public resourceType!: string;
public linkId!: string;
public publicity!: string;
public expiryAt!: Date;
// Timestamps
public createdAt!: Date;
public updatedAt!: Date;
}

View File

@@ -0,0 +1,49 @@
import { Model } from 'objection';
export class StripeAccount {
/**
* Table name
*/
static get tableName() {
return 'stripe_accounts';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return [];
}
/**
* Model modifiers.
*/
static get modifiers() {
return {};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Tenant = require('./Tenant');
return {
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: Tenant.default,
join: {
from: 'stripe_accounts.tenant_id',
to: 'tenants.id',
},
},
};
}
}

View File

@@ -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,
};

View File

@@ -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",

View File

@@ -32,7 +32,9 @@ const RegisterVerify = lazy(
const OneClickDemoPage = lazy(
() => import('@/containers/OneClickDemo/OneClickDemoPage'),
);
const PaymentPortalPage = lazy(
() => import('@/containers/PaymentPortal/PaymentPortalPage'),
);
/**
* App inner.
*/
@@ -57,6 +59,7 @@ function AppInsider({ history }) {
children={<EmailConfirmation />}
/>
<Route path={'/auth'} children={<AuthenticationPage />} />
<Route path={'/payment/:linkId'} children={<PaymentPortalPage />} />
<Route path={'/'} children={<DashboardPrivatePages />} />
</Switch>
</Router>

View File

@@ -5,6 +5,7 @@ import withDialogActions from '@/containers/Dialog/withDialogActions';
import { compose } from '@/utils';
import '@/style/components/Dialog/Dialog.scss';
import { DialogProvider } from './DialogProvider';
function DialogComponent(props) {
const { name, children, closeDialog, onClose } = props;
@@ -15,7 +16,7 @@ function DialogComponent(props) {
};
return (
<Dialog {...props} onClose={handleClose}>
{children}
<DialogProvider value={props}>{children}</DialogProvider>
</Dialog>
);
}

View File

@@ -0,0 +1,20 @@
import React, { createContext, useContext, ReactNode } from 'react';
const DialogContext = createContext<any>(null);
export const useDialogContext = () => {
return useContext(DialogContext);
};
interface DialogProviderProps {
value: any;
children: ReactNode;
}
export const DialogProvider: React.FC<DialogProviderProps> = ({ value, children }) => {
return (
<DialogContext.Provider value={value}>
{children}
</DialogContext.Provider>
);
};

View File

@@ -52,6 +52,8 @@ import PaymentMailDialog from '@/containers/Sales/PaymentsReceived/PaymentMailDi
import { ExportDialog } from '@/containers/Dialogs/ExportDialog';
import { RuleFormDialog } from '@/containers/Banking/Rules/RuleFormDialog/RuleFormDialog';
import { DisconnectBankAccountDialog } from '@/containers/CashFlow/AccountTransactions/dialogs/DisconnectBankAccountDialog/DisconnectBankAccountDialog';
import { SharePaymentLinkDialog } from '@/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog';
import { SelectPaymentMethodsDialog } from '@/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsDialog';
/**
* Dialogs container.
@@ -151,6 +153,10 @@ export default function DialogsContainer() {
<DisconnectBankAccountDialog
dialogName={DialogsName.DisconnectBankAccountConfirmation}
/>
<SharePaymentLinkDialog dialogName={DialogsName.SharePaymentLink} />
<SelectPaymentMethodsDialog
dialogName={DialogsName.SelectPaymentMethod}
/>
</div>
);
}

View File

@@ -4,9 +4,10 @@ import { FormattedMessage as T } from '@/components';
import { Classes, Icon, H4, Button } from '@blueprintjs/core';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { useDrawerContext } from './DrawerProvider';
import styled from 'styled-components';
import { compose } from '@/utils';
import styled from 'styled-components';
/**
* Drawer header content.
@@ -16,18 +17,15 @@ function DrawerHeaderContentRoot(props) {
icon,
title = <T id={'view_paper'} />,
subTitle,
onClose,
name,
closeDrawer,
} = props;
const { name } = useDrawerContext();
if (title == null) {
return null;
}
const handleClose = (event) => {
closeDrawer(name);
onClose && onClose(event);
};
return (

View File

@@ -77,4 +77,8 @@ export enum DialogsName {
Export = 'Export',
BankRuleForm = 'BankRuleForm',
DisconnectBankAccountConfirmation = 'DisconnectBankAccountConfirmation',
SharePaymentLink = 'SharePaymentLink',
SelectPaymentMethod = 'SelectPaymentMethodsDialog',
StripeSetup = 'StripeSetup'
}

View File

@@ -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'
}

View File

@@ -16,6 +16,10 @@ export default [
text: <T id={'users'} />,
href: '/preferences/users',
},
{
text: 'Payment Methods',
href: '/preferences/payment-methods'
},
{
text: <T id={'preferences.estimates'} />,
href: '/preferences/estimates',
@@ -54,6 +58,11 @@ export default [
disabled: false,
href: '/preferences/items',
},
{
text: 'Integrations',
disabled: false,
href: '/preferences/integrations'
},
// {
// text: <T id={'sms_integration.label'} />,
// disabled: false,

View File

@@ -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,
];

View File

@@ -7,6 +7,8 @@ import {
Classes,
NavbarDivider,
Intent,
Tooltip,
Position,
} from '@blueprintjs/core';
import { useInvoiceDetailDrawerContext } from './InvoiceDetailDrawerProvider';
@@ -32,6 +34,7 @@ import { compose } from '@/utils';
import { BadDebtMenuItem } from './utils';
import { DRAWERS } from '@/constants/drawers';
import { DialogsName } from '@/constants/dialogs';
import { ArrowBottomLeft } from '@/icons/ArrowBottomLeft';
/**
* Invoice details action bar.
@@ -103,6 +106,13 @@ function InvoiceDetailActionsBar({
openDialog(DialogsName.InvoiceMail, { invoiceId });
};
const handleShareButtonClick = () => {
openDialog(DialogsName.SharePaymentLink, {
transactionId: invoiceId,
transactionType: 'SaleInvoice',
});
};
return (
<DrawerActionsBar>
<NavbarGroup>
@@ -119,7 +129,7 @@ function InvoiceDetailActionsBar({
<If condition={invoice.is_delivered && !invoice.is_fully_paid}>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="arrow-downward" iconSize={18} />}
icon={<ArrowBottomLeft size={16} />}
text={<T id={'add_payment'} />}
onClick={handleQuickPaymentInvoice}
/>
@@ -150,6 +160,15 @@ function InvoiceDetailActionsBar({
onClick={handleDeleteInvoice}
/>
</Can>
<NavbarDivider />
<Tooltip content="Share" position={Position.BOTTOM} minimal>
<Button
className={Classes.MINIMAL}
icon={<Icon icon={'share'} iconSize={16} />}
onClick={handleShareButtonClick}
/>
</Tooltip>
<Can I={SaleInvoiceAction.Writeoff} a={AbilitySubject.Invoice}>
<NavbarDivider />
<BadDebtMenuItem

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Formik, Form } from 'formik';
interface SelectPaymentMethodsFormValues {}
const initialValues: SelectPaymentMethodsFormValues = {};
export const SelectPaymentMethodsForm: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const handleSubmit = (values: SelectPaymentMethodsFormValues) => {};
return (
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<Form>{children}</Form>
</Formik>
);
};

View File

@@ -0,0 +1,42 @@
import { useGetPaymentServices } from '@/hooks/query/payment-services';
import React, { createContext, useContext, ReactNode } from 'react';
interface SelectPaymentMethodsContextType {}
const SelectPaymentMethodsContext =
createContext<SelectPaymentMethodsContextType>(
{} as SelectPaymentMethodsContextType,
);
export const useSelectPaymentMethods = () => {
const context = useContext(SelectPaymentMethodsContext);
if (!context) {
throw new Error(
'useSelectPaymentMethods must be used within a SelectPaymentMethodsProvider',
);
}
return context;
};
interface SelectPaymentMethodsProviderProps {
children: ReactNode;
}
export const SelectPaymentMethodsBoot: React.FC<
SelectPaymentMethodsProviderProps
> = ({ children }) => {
const { isLoading: isPaymentServicesLoading, data: paymentServices } =
useGetPaymentServices();
const value = {
paymentServices,
isPaymentServicesLoading,
};
return (
<SelectPaymentMethodsContext.Provider value={value}>
{children}
</SelectPaymentMethodsContext.Provider>
);
};

View File

@@ -0,0 +1,114 @@
import { SelectPaymentMethodsBoot } from './SelectPaymentMethodsBoot';
import { SelectPaymentMethodsForm } from './SelectPaymemtMethodsForm';
import styled from 'styled-components';
import { Group, Stack } from '@/components';
import {
DialogFooter,
Button,
Checkbox,
DialogBody,
Intent,
Text,
} from '@blueprintjs/core';
import { useDialogActions } from '@/hooks/state';
import { DialogsName } from '@/constants/dialogs';
import { useUncontrolled } from '@/hooks/useUncontrolled';
export function SelectPaymentMethodsContent() {
const { closeDialog } = useDialogActions();
const handleCancelBtnClick = () => {
closeDialog(DialogsName.SelectPaymentMethod);
};
return (
<SelectPaymentMethodsBoot>
<SelectPaymentMethodsForm>
<DialogBody>
<Stack spacing={12}>
<PaymentMethodSelect
label={'Card (Including Apple Pay, Google Pay and Link)'}
/>
<PaymentMethodSelect
label={'Card (Including Apple Pay, Google Pay and Link)'}
/>
<PaymentMethodSelect
label={'Card (Including Apple Pay, Google Pay and Link)'}
/>
<PaymentMethodSelect
label={'Card (Including Apple Pay, Google Pay and Link)'}
/>
<PaymentMethodSelect
label={'Card (Including Apple Pay, Google Pay and Link)'}
/>
<PaymentMethodSelect
label={'Card (Including Apple Pay, Google Pay and Link)'}
/>
</Stack>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={handleCancelBtnClick}>Cancel</Button>
<Button intent={Intent.PRIMARY}>Submit</Button>
</>
}
></DialogFooter>
</SelectPaymentMethodsForm>
</SelectPaymentMethodsBoot>
);
}
interface PaymentMethodSelectProps {
label: string;
value?: boolean;
initialValue?: boolean;
onChange?: (value: boolean) => void;
}
function PaymentMethodSelect({
value,
initialValue,
onChange,
label,
}: PaymentMethodSelectProps) {
const [_value, handleChange] = useUncontrolled<boolean>({
value,
initialValue,
finalValue: false,
onChange,
});
const handleClick = () => {
handleChange(!_value);
};
return (
<PaymentMethodSelectRoot onClick={handleClick}>
<PaymentMethodCheckbox
label={''}
checked={_value}
onClick={handleClick}
/>
<PaymentMethodText>{label}</PaymentMethodText>
</PaymentMethodSelectRoot>
);
}
const PaymentMethodSelectRoot = styled(Group)`
border: 1px solid #d3d8de;
border-radius: 5px;
padding: 10px;
gap: 0;
cursor: pointer;
`;
const PaymentMethodCheckbox = styled(Checkbox)`
margin: 0;
&.bp4-control .bp4-control-indicator {
box-shadow: 0 0 0 1px #c5cbd3;
}
`;
const PaymentMethodText = styled(Text)`
color: #404854;
`;

View File

@@ -0,0 +1,38 @@
// @ts-nocheck
import React from 'react';
import { Dialog, DialogSuspense } from '@/components';
import withDialogRedux from '@/components/DialogReduxConnect';
import { compose } from '@/utils';
const SelectPaymentMethodsDialogContent = React.lazy(() =>
import('./SelectPaymentMethodsContent').then((module) => ({
default: module.SelectPaymentMethodsContent,
})),
);
/**
* Select payment methods dialogs.
*/
function SelectPaymentMethodsDialogRoot({ dialogName, payload, isOpen }) {
return (
<Dialog
name={dialogName}
isOpen={isOpen}
payload={payload}
title={'Share Link'}
canEscapeJeyClose={true}
autoFocus={true}
style={{ width: 570 }}
>
<DialogSuspense>
<SelectPaymentMethodsDialogContent />
</DialogSuspense>
</Dialog>
);
}
export const SelectPaymentMethodsDialog = compose(withDialogRedux())(
SelectPaymentMethodsDialogRoot,
);
SelectPaymentMethodsDialog.displayName = 'SelectPaymentMethodsDialog';

View File

@@ -0,0 +1,13 @@
import { SharePaymentLinkForm } from './SharePaymentLinkForm';
import { SharePaymentLinkFormContent } from './SharePaymentLinkFormContent';
import { SharePaymentLinkProvider } from './SharePaymentLinkProvider';
export function SharePaymentLinkContent() {
return (
<SharePaymentLinkProvider>
<SharePaymentLinkForm>
<SharePaymentLinkFormContent />
</SharePaymentLinkForm>
</SharePaymentLinkProvider>
);
}

View File

@@ -0,0 +1,38 @@
// @ts-nocheck
import React from 'react';
import { Dialog, DialogSuspense } from '@/components';
import withDialogRedux from '@/components/DialogReduxConnect';
import { compose } from '@/utils';
const SharePaymentLinkContent = React.lazy(() =>
import('./SharePaymentLinkContent').then((module) => ({
default: module.SharePaymentLinkContent,
})),
);
/**
*
*/
function SharePaymentLinkDialogRoot({ dialogName, payload, isOpen }) {
return (
<Dialog
name={dialogName}
isOpen={isOpen}
payload={payload}
title={'Share Link'}
canEscapeJeyClose={true}
autoFocus={true}
style={{ width: 570 }}
>
<DialogSuspense>
<SharePaymentLinkContent />
</DialogSuspense>
</Dialog>
);
}
export const SharePaymentLinkDialog = compose(withDialogRedux())(
SharePaymentLinkDialogRoot,
);
SharePaymentLinkDialog.displayName = 'SharePaymentLinkDialog';

View File

@@ -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'),
});

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { Intent } from '@blueprintjs/core';
import { Formik, Form, FormikHelpers } from 'formik';
import moment from 'moment';
import { useCreatePaymentLink } from '@/hooks/query/payment-link';
import { AppToaster } from '@/components';
import { SharePaymentLinkFormSchema } from './SharePaymentLinkForm.schema';
import { useDialogContext } from '@/components/Dialog/DialogProvider';
import { useSharePaymentLink } from './SharePaymentLinkProvider';
interface SharePaymentLinkFormProps {
children: React.ReactNode;
}
interface SharePaymentLinkFormValues {
publicity: string;
expiryDate: string;
transactionId: string;
transactionType: string;
}
const initialValues = {
publicity: 'public',
expiryDate: moment().add(30, 'days').format('YYYY-MM-DD'),
transactionId: '',
transactionType: '',
};
export const SharePaymentLinkForm = ({
children,
}: SharePaymentLinkFormProps) => {
const { mutateAsync: generateShareLink } = useCreatePaymentLink();
const { payload } = useDialogContext();
const { setUrl } = useSharePaymentLink();
const transactionId = payload?.transactionId;
const transactionType = payload?.transactionType;
const formInitialValues = {
...initialValues,
transactionType,
transactionId,
};
const handleFormSubmit = (
values: SharePaymentLinkFormValues,
{ setSubmitting }: FormikHelpers<SharePaymentLinkFormValues>,
) => {
setSubmitting(true);
generateShareLink(values)
.then((res) => {
setSubmitting(false);
setUrl(res.link?.link);
})
.catch(() => {
setSubmitting(false);
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
return (
<Formik<SharePaymentLinkFormValues>
initialValues={formInitialValues}
validationSchema={SharePaymentLinkFormSchema}
onSubmit={handleFormSubmit}
>
<Form>{children}</Form>
</Formik>
);
};
SharePaymentLinkForm.displayName = 'SharePaymentLinkForm';

View File

@@ -0,0 +1,142 @@
// @ts-nocheck
import { useFormikContext } from 'formik';
import {
Button,
Classes,
DialogBody,
DialogFooter,
FormGroup,
InputGroup,
Intent,
Position,
Tooltip,
} from '@blueprintjs/core';
import {
DialogFooterActions,
FDateInput,
FFormGroup,
FSelect,
Icon,
Stack,
} from '@/components';
import { useSharePaymentLink } from './SharePaymentLinkProvider';
import { useClipboard } from '@/hooks/utils/useClipboard';
import { useDialogActions } from '@/hooks/state';
import { useDialogContext } from '@/components/Dialog/DialogProvider';
export function SharePaymentLinkFormContent() {
const { url } = useSharePaymentLink();
const { closeDialog } = useDialogActions();
const { name } = useDialogContext();
const { isSubmitting } = useFormikContext();
const clipboard = useClipboard();
const handleCopyBtnClick = () => {
clipboard.copy(url);
};
const handleCancelBtnClick = () => {
closeDialog(name);
};
return (
<>
<DialogBody>
<Stack spacing={0}>
<FFormGroup
name={'publicity'}
label={'Visibility'}
style={{ marginBottom: 10 }}
inline
>
<FSelect
name={'publicity'}
items={[
{ value: 'private', text: 'Private' },
{ value: 'public', text: 'Public' },
]}
input={({ activeItem, text, label, value }) => (
<Button
text={text || 'Select an item ...'}
rightIcon={<Icon icon={'caret-down-16'} iconSize={16} />}
minimal
/>
)}
searchable={false}
fastField
/>
</FFormGroup>
<p className={Classes.TEXT_MUTED} style={{ marginBottom: 20 }}>
Select an expiration date and generate the link to share it with
your customer. Remember that anyone who has access to this link can
view, print or download it.
</p>
<FFormGroup
name={'expiryDate'}
label={'Expiration Date'}
helperText={
'By default, the link is set to expire 90 days from today.'
}
fastField
>
<FDateInput
name={'expiryDate'}
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{
fill: true,
style: { minWidth: 260 },
leftElement: <Icon icon={'date-range'} />,
}}
fastField
/>
</FFormGroup>
{url && (
<FormGroup name={'link'} label={'Payment Link'}>
<InputGroup
name={'link'}
value={url}
disabled
leftElement={
<Tooltip content="Copy to clipboard" position={Position.TOP}>
<Button
onClick={handleCopyBtnClick}
minimal
icon={<Icon icon={'clipboard'} iconSize={16} />}
/>
</Tooltip>
}
/>
</FormGroup>
)}
</Stack>
</DialogBody>
<DialogFooter>
<DialogFooterActions>
{url ? (
<Button intent={Intent.PRIMARY} onClick={handleCopyBtnClick}>
Copy Link
</Button>
) : (
<>
<Button onClick={handleCancelBtnClick}>Cancel</Button>
<Button
type={'submit'}
intent={Intent.PRIMARY}
loading={isSubmitting}
style={{ minWidth: 100 }}
>
Generate
</Button>
</>
)}
</DialogFooterActions>
</DialogFooter>
</>
);
}

View File

@@ -0,0 +1,35 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface SharePaymentLinkContextType {
url: string;
setUrl: React.Dispatch<React.SetStateAction<string>>;
}
const SharePaymentLinkContext =
createContext<SharePaymentLinkContextType | null>(null);
interface SharePaymentLinkProviderProps {
children: ReactNode;
}
export const SharePaymentLinkProvider: React.FC<
SharePaymentLinkProviderProps
> = ({ children }) => {
const [url, setUrl] = useState<string>('');
return (
<SharePaymentLinkContext.Provider value={{ url, setUrl }}>
{children}
</SharePaymentLinkContext.Provider>
);
};
export const useSharePaymentLink = () => {
const context = useContext(SharePaymentLinkContext);
if (!context) {
throw new Error(
'useSharePaymentLink must be used within a SharePaymentLinkProvider',
);
}
return context;
};

View File

@@ -0,0 +1,84 @@
import { FCheckbox, Group } from '@/components';
import { useUncontrolled } from '@/hooks/useUncontrolled';
import { Checkbox, Text } from '@blueprintjs/core';
import { useFormikContext } from 'formik';
import { get } from 'lodash';
import { useMemo } from 'react';
import styled from 'styled-components';
export interface PaymentMethodSelectProps {
label: string;
value?: boolean;
initialValue?: boolean;
onChange?: (value: boolean) => void;
}
export function PaymentMethodSelect({
value,
initialValue,
onChange,
label,
}: PaymentMethodSelectProps) {
const [_value, handleChange] = useUncontrolled<boolean>({
value,
initialValue,
finalValue: false,
onChange,
});
const handleClick = () => {
handleChange(!_value);
};
return (
<PaymentMethodSelectRoot onClick={handleClick}>
<PaymentMethodCheckbox
label={''}
checked={_value}
onClick={handleClick}
/>
<PaymentMethodText>{label}</PaymentMethodText>
</PaymentMethodSelectRoot>
);
}
export interface PaymentMethodSelectFieldProps
extends Partial<PaymentMethodSelectProps> {
label: string;
name: string;
}
export function PaymentMethodSelectField({
name,
...props
}: PaymentMethodSelectFieldProps) {
const { values, setFieldValue } = useFormikContext();
const value = useMemo(() => get(values, name), [values, name]);
const handleChange = (newValue: boolean) => {
setFieldValue(name, newValue);
};
return (
<PaymentMethodSelect value={value} onChange={handleChange} {...props} />
);
}
const PaymentMethodSelectRoot = styled(Group)`
border: 1px solid #d3d8de;
border-radius: 3px;
padding: 8px;
gap: 0;
min-width: 200px;
cursor: pointer;
`;
const PaymentMethodCheckbox = styled(Checkbox)`
margin: 0;
&.bp4-control .bp4-control-indicator {
box-shadow: 0 0 0 1px #c5cbd3;
}
`;
const PaymentMethodText = styled(Text)`
color: #404854;
`;

View File

@@ -0,0 +1,52 @@
import styled from 'styled-components';
import React from 'react';
import {
Classes,
Popover,
PopoverInteractionKind,
Position,
} from '@blueprintjs/core';
import { Stack } from '@/components';
import { PaymentMethodSelectField } from './PaymentMethodSelect';
interface PaymentOptionsButtonPopverProps {
paymentMethods: Array<any>;
children: React.ReactNode;
}
export function PaymentOptionsButtonPopver({
paymentMethods,
children,
}: PaymentOptionsButtonPopverProps) {
return (
<Popover
interactionKind={PopoverInteractionKind.HOVER}
position={Position.TOP_RIGHT}
popoverClassName={Classes.POPOVER_CONTENT_SIZING}
minimal={true}
content={
<Stack spacing={8}>
<PaymentMethodsTitle>Payment Options</PaymentMethodsTitle>
<Stack spacing={8}>
{paymentMethods?.map((service, key) => (
<PaymentMethodSelectField
name={`payment_methods.${service.id}.enable`}
label={'Card (Stripe)'}
key={key}
/>
))}
</Stack>
</Stack>
}
>
{children}
</Popover>
);
}
const PaymentMethodsTitle = styled('h6')`
font-size: 12px;
font-weight: 500;
margin: 0;
color: rgb(95, 107, 124);
`;

View File

@@ -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;
}

View File

@@ -0,0 +1,157 @@
import { Text, Classes, Button, Intent } from '@blueprintjs/core';
import clsx from 'classnames';
import { AppToaster, Box, Group, Stack } from '@/components';
import { usePaymentPortalBoot } from './PaymentPortalBoot';
import { useDrawerActions } from '@/hooks/state';
import { useCreateStripeCheckoutSession } from '@/hooks/query/stripe-integration';
import { DRAWERS } from '@/constants/drawers';
import styles from './PaymentPortal.module.scss';
export function PaymentPortal() {
const { openDrawer } = useDrawerActions();
const { sharableLinkMeta, linkId } = usePaymentPortalBoot();
const {
mutateAsync: createStripeCheckoutSession,
isLoading: isStripeCheckoutLoading,
} = useCreateStripeCheckoutSession();
// Handles invoice preview button click.
const handleInvoicePreviewBtnClick = () => {
openDrawer(DRAWERS.PAYMENT_INVOICE_PREVIEW);
};
// handles the pay button click.
const handlePayButtonClick = () => {
createStripeCheckoutSession({ linkId })
.then((session) => {
window.open(session.redirectTo);
})
.catch((error) => {
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
});
};
return (
<Box className={styles.root}>
<Stack spacing={0} className={styles.body}>
<Stack>
<Group spacing={10}>
<Box className={styles.companyLogoWrap}></Box>
<Text>{sharableLinkMeta?.companyName}</Text>
</Group>
<Stack spacing={6}>
<h1 className={styles.bigTitle}>
{sharableLinkMeta?.companyName} Sent an Invoice for{' '}
{sharableLinkMeta?.totalFormatted}
</h1>
<Text className={clsx(Classes.TEXT_MUTED, styles.invoiceDueDate)}>
Invoice due {sharableLinkMeta?.dueDateFormatted}
</Text>
</Stack>
<Stack className={styles.address} spacing={2}>
<Box className={styles.customerName}>
{sharableLinkMeta?.customerName}
</Box>
<Box>Bigcapital Technology, Inc.</Box>
<Box>131 Continental Dr Suite 305 Newark,</Box>
<Box>Delaware 19713</Box>
<Box>United States</Box>
<Box>ahmed@bigcapital.app</Box>
</Stack>
<h2 className={styles.invoiceNumber}>
Invoice {sharableLinkMeta?.invoiceNo}
</h2>
<Stack spacing={0} className={styles.totals}>
<Group
position={'apart'}
className={clsx(styles.totalItem, styles.borderBottomGray)}
>
<Text>Sub Total</Text>
<Text>{sharableLinkMeta?.subtotalFormatted}</Text>
</Group>
<Group position={'apart'} className={styles.totalItem}>
<Text>Total</Text>
<Text style={{ fontWeight: 500 }}>
{sharableLinkMeta?.totalFormatted}
</Text>
</Group>
<Group
position={'apart'}
className={clsx(styles.totalItem, styles.borderBottomGray)}
>
<Text>Paid Amount (-)</Text>
<Text>{sharableLinkMeta?.paymentAmountFormatted}</Text>
</Group>
<Group
position={'apart'}
className={clsx(styles.totalItem, styles.borderBottomDark)}
>
<Text>Due Amount</Text>
<Text style={{ fontWeight: 500 }}>
{sharableLinkMeta?.dueAmountFormatted}
</Text>
</Group>
</Stack>
</Stack>
<Stack spacing={8} className={styles.footerButtons}>
<Button
minimal
className={clsx(styles.footerButton, styles.downloadInvoiceButton)}
>
Download Invoice
</Button>
<Button
onClick={handleInvoicePreviewBtnClick}
className={clsx(styles.footerButton, styles.viewInvoiceButton)}
>
View Invoice
</Button>
<Button
intent={Intent.PRIMARY}
className={clsx(styles.footerButton, styles.buyButton)}
loading={isStripeCheckoutLoading}
onClick={handlePayButtonClick}
>
Pay {sharableLinkMeta?.totalFormatted}
</Button>
</Stack>
<Text className={clsx(Classes.TEXT_MUTED, styles.buyNote)}>
By confirming your payment, you allow Bigcapital Technology, Inc. to
charge you for this payment and save your payment information in
accordance with their terms.
</Text>
</Stack>
<Stack spacing={18} className={styles.footer}>
<Stack spacing={0}>
<Box>
<strong>Bigcapital Technology, Inc.</strong>
</Box>
<Box>131 Continental Dr Suite 305 Newark,</Box>
<Box>Delaware 19713</Box>
<Box>United States</Box>
<Box>ahmed@bigcapital.app</Box>
</Stack>
<Stack spacing={0} className={styles.footerText}>
© 2024 Bigcapital Technology, Inc.
<br />
All rights reserved.
</Stack>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,55 @@
import React, { createContext, useContext, ReactNode } from 'react';
import {
GetInvoicePaymentLinkResponse,
useGetInvoicePaymentLink,
} from '@/hooks/query/payment-link';
import { Spinner } from '@blueprintjs/core';
interface PaymentPortalContextType {
linkId: string;
sharableLinkMeta: GetInvoicePaymentLinkResponse | undefined;
isSharableLinkMetaLoading: boolean;
}
const PaymentPortalContext = createContext<PaymentPortalContextType>(
{} as PaymentPortalContextType,
);
interface PaymentPortalBootProps {
linkId: string;
children: ReactNode;
}
export const PaymentPortalBoot: React.FC<PaymentPortalBootProps> = ({
linkId,
children,
}) => {
const { data: sharableLinkMeta, isLoading: isSharableLinkMetaLoading } =
useGetInvoicePaymentLink(linkId);
const value = {
linkId,
sharableLinkMeta,
isSharableLinkMetaLoading,
};
if (isSharableLinkMetaLoading) {
return <Spinner size={20} />;
}
return (
<PaymentPortalContext.Provider value={value}>
{children}
</PaymentPortalContext.Provider>
);
};
export const usePaymentPortalBoot = (): PaymentPortalContextType => {
const context = useContext(PaymentPortalContext);
if (!context) {
throw new Error(
'usePaymentPortal must be used within a PaymentPortalProvider',
);
}
return context;
};

View File

@@ -0,0 +1,20 @@
import { useParams } from 'react-router-dom';
import { PaymentPortal } from './PaymentPortal';
import { PaymentPortalBoot } from './PaymentPortalBoot';
import BodyClassName from 'react-body-classname';
import styles from './PaymentPortal.module.scss';
import { PaymentInvoicePreviewDrawer } from './drawers/PaymentInvoicePreviewDrawer/PaymentInvoicePreviewDrawer';
import { DRAWERS } from '@/constants/drawers';
export default function PaymentPortalPage() {
const { linkId } = useParams<{ linkId: string }>();
return (
<BodyClassName className={styles.rootBodyPage}>
<PaymentPortalBoot linkId={linkId}>
<PaymentPortal />
<PaymentInvoicePreviewDrawer name={DRAWERS.PAYMENT_INVOICE_PREVIEW} />
</PaymentPortalBoot>
</BodyClassName>
);
}

View File

@@ -0,0 +1,38 @@
// @ts-nocheck
import { Box, DrawerBody, DrawerHeaderContent } from '@/components';
import { InvoicePaperTemplate } from '@/containers/Sales/Invoices/InvoiceCustomize/InvoicePaperTemplate';
import { usePaymentPortalBoot } from '../../PaymentPortalBoot';
export function PaymentInvoicePreviewContent() {
const { sharableLinkMeta } = usePaymentPortalBoot();
return (
<>
<DrawerHeaderContent title={'Invoice'} />
<DrawerBody>
<Box style={{ paddingTop: 20, paddingBottom: 20 }}>
<InvoicePaperTemplate
invoiceNumber={sharableLinkMeta?.invoiceNo}
dueDate={sharableLinkMeta?.dueDateFormatted}
dateIssue={sharableLinkMeta?.invoiceDateFormatted}
total={sharableLinkMeta?.totalFormatted}
subtotal={sharableLinkMeta?.subtotalFormatted}
balanceDue={sharableLinkMeta?.dueAmountFormatted}
paymentMade={sharableLinkMeta?.paymentAmountFormatted}
termsConditions={sharableLinkMeta?.termsConditions}
statement={sharableLinkMeta?.invoiceMessage}
companyName={sharableLinkMeta?.companyName}
lines={sharableLinkMeta?.entries?.map((entry) => ({
item: entry.itemName,
description: entry.description,
quantity: entry.quantityFormatted,
rate: entry.rateFormatted,
total: entry.totalFormatted,
}))}
/>
</Box>
</DrawerBody>
</>
);
}

View File

@@ -0,0 +1,37 @@
// @ts-nocheck
import React from 'react';
import * as R from 'ramda';
import { Position } from '@blueprintjs/core';
import { Drawer, DrawerSuspense } from '@/components';
import withDrawers from '@/containers/Drawer/withDrawers';
import { PaymentInvoicePreviewContent } from './PaymentInvoicePreviewContent';
/**
*
* @returns {React.ReactNode}
*/
function PaymentInvoicePreviewDrawerRoot({
name,
// #withDrawer
isOpen,
payload,
}) {
return (
<Drawer
isOpen={isOpen}
name={name}
size={'100%'}
style={{ background: '#F6F7F9' }}
position={Position.TOP}
payload={payload}
>
<DrawerSuspense>
<PaymentInvoicePreviewContent />
</DrawerSuspense>
</Drawer>
);
}
export const PaymentInvoicePreviewDrawer = R.compose(withDrawers())(
PaymentInvoicePreviewDrawerRoot,
);

View File

@@ -0,0 +1,47 @@
import { createContext, ReactNode, useContext } from 'react';
import { Spinner } from '@blueprintjs/core';
import {
GetPaymentServicesStateResponse,
useGetPaymentServicesState,
} from '@/hooks/query/payment-services';
type PaymentMethodsContextType = {
isPaymentMethodsStateLoading: boolean;
paymentMethodsState: GetPaymentServicesStateResponse | undefined;
};
const PaymentMethodsContext = createContext<PaymentMethodsContextType>(
{} as PaymentMethodsContextType,
);
type PaymentMethodsProviderProps = {
children: ReactNode;
};
const PaymentMethodsBoot = ({ children }: PaymentMethodsProviderProps) => {
const { data: paymentMethodsState, isLoading: isPaymentMethodsStateLoading } =
useGetPaymentServicesState();
const value = { isPaymentMethodsStateLoading, paymentMethodsState };
if (isPaymentMethodsStateLoading) {
return <Spinner size={20} />;
}
return (
<PaymentMethodsContext.Provider value={value}>
{children}
</PaymentMethodsContext.Provider>
);
};
const usePaymentMethodsBoot = () => {
const context = useContext<PaymentMethodsContextType>(PaymentMethodsContext);
if (context === undefined) {
throw new Error(
'usePaymentMethods must be used within a PaymentMethodsProvider',
);
}
return context;
};
export { PaymentMethodsBoot, usePaymentMethodsBoot };

View File

@@ -0,0 +1,50 @@
// @ts-nocheck
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { Classes, Text } from '@blueprintjs/core';
import { Box, Stack } from '@/components';
import { PaymentMethodsBoot } from './PreferencesPaymentMethodsBoot';
import { StripePreSetupDialog } from './dialogs/StripePreSetupDialog/StripePreSetupDialog';
import { useChangePreferencesPageTitle } from '@/hooks/state';
import { StripeIntegrationEditDrawer } from './drawers/StripeIntegrationEditDrawer';
import { StripePaymentMethod } from './StripePaymentMethod';
import { DialogsName } from '@/constants/dialogs';
import { DRAWERS } from '@/constants/drawers';
/**
* Payment methods page.
* @returns {JSX.Element}
*/
export default function PreferencesPaymentMethodsPage() {
const changePageTitle = useChangePreferencesPageTitle();
useEffect(() => {
changePageTitle('Payment Methods');
}, [changePageTitle]);
return (
<PaymentMethodsRoot>
<PaymentMethodsBoot>
<Text className={Classes.TEXT_MUTED} style={{ marginBottom: 20 }}>
Accept payments from all the major debit and credit card networks
through the supported payment methods.
</Text>
<Stack>
<StripePaymentMethod />
</Stack>
<StripePreSetupDialog dialogName={DialogsName.StripeSetup} />
<StripeIntegrationEditDrawer
name={DRAWERS.STRIPE_PAYMENT_INTEGRATION_EDIT}
/>
</PaymentMethodsBoot>
</PaymentMethodsRoot>
);
}
const PaymentMethodsRoot = styled(Box)`
witdth: 100%;
max-width: 700px;
margin: 20px;
`;

View File

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

View File

@@ -0,0 +1,156 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import {
Button,
Classes,
Intent,
Menu,
MenuItem,
Popover,
Tag,
Text,
Tooltip,
} from '@blueprintjs/core';
import { Box, Card, Group, Stack } from '@/components';
import { StripeLogo } from '@/icons/StripeLogo';
import { usePaymentMethodsBoot } from './PreferencesPaymentMethodsBoot';
import { DialogsName } from '@/constants/dialogs';
import {
useAlertActions,
useDialogActions,
useDrawerActions,
} from '@/hooks/state';
import { DRAWERS } from '@/constants/drawers';
import { MoreIcon } from '@/icons/More';
import { STRIPE_PRICING_LINK } from './constants';
export function StripePaymentMethod() {
const { openDialog } = useDialogActions();
const { openDrawer } = useDrawerActions();
const { openAlert } = useAlertActions();
const { paymentMethodsState } = usePaymentMethodsBoot();
const stripeState = paymentMethodsState?.stripe;
const isAccountCreated = stripeState?.isStripeAccountCreated;
const isPaymentEnabled = stripeState?.isStripePaymentEnabled;
const isPayoutEnabled = stripeState?.isStripePayoutEnabled;
const isStripeEnabled = stripeState?.isStripeEnabled;
const stripePaymentMethodId = stripeState?.stripePaymentMethodId;
const isStripeServerConfigured = stripeState?.isStripeServerConfigured;
// Handle Stripe setup button click.
const handleSetUpBtnClick = () => {
openDialog(DialogsName.StripeSetup);
};
// Handle edit button click.
const handleEditBtnClick = () => {
openDrawer(DRAWERS.STRIPE_PAYMENT_INTEGRATION_EDIT, {
stripePaymentMethodId: stripePaymentMethodId,
});
};
// Handle delete connection button click.
const handleDeleteConnectionClick = () => {
openAlert('delete-stripe-payment-method', {
paymentMethodId: stripePaymentMethodId,
});
};
return (
<Card style={{ margin: 0 }}>
<Group position="apart">
<Group>
<StripeLogo />
<Group spacing={10}>
{isStripeEnabled && (
<Tag minimal intent={Intent.SUCCESS}>
Active
</Tag>
)}
{!isPaymentEnabled && isAccountCreated && (
<Tooltip content="The account cannot accept payments because verification may be incomplete, there may be legal or compliance issues, or required documents haven't been submitted or verified.">
<Tag minimal intent={Intent.DANGER}>
Payment Not Enabled
</Tag>
</Tooltip>
)}
{!isPayoutEnabled && isAccountCreated && (
<Tooltip content="The account cannot receive payouts due to incomplete or invalid bank details, pending identity verification, or compliance restrictions.">
<Tag minimal intent={Intent.DANGER}>
Payout Not Enabled
</Tag>
</Tooltip>
)}
</Group>
</Group>
<Group spacing={10}>
{isAccountCreated && (
<Button small onClick={handleEditBtnClick}>
Edit
</Button>
)}
{!isAccountCreated && (
<Button intent={Intent.PRIMARY} small onClick={handleSetUpBtnClick}>
Set it Up
</Button>
)}
{isAccountCreated && (
<Popover
content={
<Menu>
<MenuItem
intent={Intent.DANGER}
text={'Delete Connection'}
onClick={handleDeleteConnectionClick}
/>
</Menu>
}
>
<Button small icon={<MoreIcon size={16} />} />
</Popover>
)}
</Group>
</Group>
<PaymentDescription
className={Classes.TEXT_MUTED}
style={{ fontSize: 13 }}
>
Stripe is a secure online payment platform that lets you easily accept
both one-time and recurring payments. It simplifies managing
transactions and streamlines reconciliation. Setup is quick, helping you
get paid faster and more efficiently.
</PaymentDescription>
<PaymentFooter>
<Stack spacing={10}>
<Text>
<a target="_blank" rel="noreferrer" href={STRIPE_PRICING_LINK}>
View Stripe's Transaction Fees
</a>
</Text>
{!isStripeServerConfigured && (
<Text style={{ color: '#CD4246' }}>
Stripe payment is not configured from the server.{' '}
</Text>
)}
</Stack>
</PaymentFooter>
</Card>
);
}
const PaymentDescription = styled(Text)`
font-size: 13px;
margin-top: 12px;
`;
const PaymentFooter = styled(Box)`
margin-top: 14px;
font-size: 12px;
`;

View File

@@ -0,0 +1,68 @@
// @ts-nocheck
import React from 'react';
import { Intent, Alert } from '@blueprintjs/core';
import { AppToaster, FormattedMessage as T } from '@/components';
import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect';
import withAlertActions from '@/containers/Alert/withAlertActions';
import { useDeletePaymentMethod } from '@/hooks/query/payment-services';
import { compose } from '@/utils';
/**
* Delete Stripe connection alert.
*/
function DeleteStripeAccountAlert({
name,
// #withAlertStoreConnect
isOpen,
payload: { paymentMethodId },
// #withAlertActions
closeAlert,
}) {
const { isLoading, mutateAsync: deletePaymentMethod } =
useDeletePaymentMethod();
// Handle cancel open bill alert.
const handleCancelOpenBill = () => {
closeAlert(name);
};
// Handle confirm bill open.
const handleConfirmBillOpen = () => {
deletePaymentMethod({ paymentMethodId })
.then(() => {
AppToaster.show({
message: 'The Stripe payment account has been deleted.',
intent: Intent.SUCCESS,
});
closeAlert(name);
})
.catch((error) => {
closeAlert(name);
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.SUCCESS,
});
});
};
return (
<Alert
cancelButtonText={<T id={'cancel'} />}
confirmButtonText={'Delete Account'}
intent={Intent.DANGER}
isOpen={isOpen}
onCancel={handleCancelOpenBill}
onConfirm={handleConfirmBillOpen}
loading={isLoading}
>
<p>Are you sure want to delete your Stripe account connection?</p>
</Alert>
);
}
export default compose(
withAlertStoreConnect(),
withAlertActions,
)(DeleteStripeAccountAlert);

View File

@@ -0,0 +1,13 @@
// @ts-nocheck
import React from 'react';
const DeleteStripeConnectionAlert = React.lazy(
() => import('./DeleteStripeConnectionAlert'),
);
export const PaymentMethodsAlerts = [
{
name: 'delete-stripe-payment-method',
component: DeleteStripeConnectionAlert,
},
];

View File

@@ -0,0 +1 @@
export const STRIPE_PRICING_LINK = 'https://stripe.com/pricing';

View File

@@ -0,0 +1,33 @@
// @ts-nocheck
import React from 'react';
import { Dialog, DialogSuspense } from '@/components';
import { compose } from '@/utils';
import withDialogRedux from '@/components/DialogReduxConnect';
import { StripePreSetupDialogContent } from './StripePreSetupDialogContent';
/**
* Select payment methods dialogs.
*/
function StripePreSetupDialogRoot({ dialogName, payload, isOpen }) {
return (
<Dialog
name={dialogName}
isOpen={isOpen}
payload={payload}
title={'Connect a Stripe account to accept card payments'}
canEscapeJeyClose={true}
autoFocus={true}
style={{ width: 500 }}
>
<DialogSuspense>
<StripePreSetupDialogContent />
</DialogSuspense>
</Dialog>
);
}
export const StripePreSetupDialog = compose(withDialogRedux())(
StripePreSetupDialogRoot,
);
StripePreSetupDialogRoot.displayName = 'StripePreSetupDialog';

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { Button, DialogBody, DialogFooter, Intent } from '@blueprintjs/core';
import styled from 'styled-components';
import { Stack } from '@/components';
import { useDialogContext } from '@/components/Dialog/DialogProvider';
import { useDialogActions } from '@/hooks/state';
import { CreditCard2Icon } from '@/icons/CreditCard2';
import { DollarIcon } from '@/icons/Dollar';
import { LayoutAutoIcon } from '@/icons/LayoutAuto';
import { SwitchIcon } from '@/icons/SwitchIcon';
import { usePaymentMethodsBoot } from '../../PreferencesPaymentMethodsBoot';
export function StripePreSetupDialogContent() {
const { name } = useDialogContext();
const { closeDialog } = useDialogActions();
const { paymentMethodsState } = usePaymentMethodsBoot();
const [isRedirecting, setIsRedirecting] = useState<boolean>(false);
const handleSetUpBtnClick = () => {
if (paymentMethodsState?.stripe.stripeAuthLink) {
setIsRedirecting(true);
window.location.href = paymentMethodsState?.stripe.stripeAuthLink;
}
};
// Handle cancel button click.
const handleCancelBtnClick = () => {
closeDialog(name);
};
return (
<>
<DialogBody>
<Stack style={{ paddingTop: 10, paddingBottom: 20 }}>
<PaymentFeatureItem>
<PaymentFeatureIcon>
<LayoutAutoIcon size={16} />
</PaymentFeatureIcon>{' '}
If you're already using Stripe, you can connect your Stripe account
to Bigcapital.
</PaymentFeatureItem>
<PaymentFeatureItem>
<PaymentFeatureIcon>
<DollarIcon size={16} />
</PaymentFeatureIcon>{' '}
Stripe applies a processing fee for each card payment, but we only
charge for the application subscription.
</PaymentFeatureItem>
<PaymentFeatureItem>
<PaymentFeatureIcon>
<CreditCard2Icon size={16} />
</PaymentFeatureIcon>{' '}
Customers can pay invoice using credit card, debit card or digital
wallets like Apple Pay or Google Pay.
</PaymentFeatureItem>
<PaymentFeatureItem>
<PaymentFeatureIcon>
<SwitchIcon size={16} />
</PaymentFeatureIcon>{' '}
You can enable or disable card payments for each invoice
</PaymentFeatureItem>
</Stack>
</DialogBody>
<DialogFooter
actions={
<>
<Button onClick={handleCancelBtnClick}>Cancel</Button>
<Button
intent={Intent.PRIMARY}
onClick={handleSetUpBtnClick}
loading={isRedirecting}
>
Set Up Stripe
</Button>
</>
}
></DialogFooter>
</>
);
}
const PaymentFeatureItem = styled('div')`
padding-left: 20px;
position: relative;
padding-left: 50px;
`;
const PaymentFeatureIcon = styled('span')`
position: absolute;
left: 12px;
top: 2px;
color: #0052cc;
`;

View File

@@ -0,0 +1,62 @@
import React, { createContext, useContext } from 'react';
import { Spinner } from '@blueprintjs/core';
import { useAccounts } from '@/hooks/query';
import { useGetPaymentMethod } from '@/hooks/query/payment-services';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
interface StripeIntegrationEditContextType {
accounts: any;
isAccountsLoading: boolean;
paymentMethod: any;
isPaymentMethodLoading: boolean;
}
const StripeIntegrationEditContext =
createContext<StripeIntegrationEditContextType>(
{} as StripeIntegrationEditContextType,
);
export const useStripeIntegrationEditBoot = () => {
const context = useContext<StripeIntegrationEditContextType>(
StripeIntegrationEditContext,
);
if (!context) {
throw new Error(
'useStripeIntegrationEditContext must be used within a StripeIntegrationEditProvider',
);
}
return context;
};
export const StripeIntegrationEditBoot: React.FC = ({ children }) => {
const {
payload: { stripePaymentMethodId },
} = useDrawerContext();
const { data: accounts, isLoading: isAccountsLoading } = useAccounts({}, {});
const { data: paymentMethod, isLoading: isPaymentMethodLoading } =
useGetPaymentMethod(stripePaymentMethodId, {
enabled: !!stripePaymentMethodId,
});
const value = {
// Accounts.
accounts,
isAccountsLoading,
// Payment methods.
paymentMethod,
isPaymentMethodLoading,
};
const isLoading = isAccountsLoading || isPaymentMethodLoading;
if (isLoading) {
return <Spinner size={20} />;
}
return (
<StripeIntegrationEditContext.Provider value={value}>
{children}
</StripeIntegrationEditContext.Provider>
);
};

View File

@@ -0,0 +1,29 @@
// @ts-nocheck
import { Classes } from '@blueprintjs/core';
import { DrawerBody, DrawerHeaderContent } from '@/components';
import { StripeIntegrationEditForm } from './StripeIntegrationEditForm';
import { StripeIntegrationEditBoot } from './StripeIntegrationEditBoot';
import {
StripeIntegrationEditFormContent,
StripeIntegrationEditFormFooter,
} from './StripeIntegrationEditFormContent';
export function StripeIntegrationEditContent() {
return (
<>
<DrawerHeaderContent title={'Edit Stripe Integration'} />
<StripeIntegrationEditBoot>
<StripeIntegrationEditForm>
<DrawerBody>
<StripeIntegrationEditFormContent />
</DrawerBody>
<div className={Classes.DRAWER_FOOTER}>
<StripeIntegrationEditFormFooter />
</div>
</StripeIntegrationEditForm>
</StripeIntegrationEditBoot>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More