feat: integrate Stripe payment to invoices

This commit is contained in:
Ahmed Bouhuolia
2024-09-18 19:24:01 +02:00
parent df706d2573
commit 4665f529e6
24 changed files with 540 additions and 80 deletions

View File

@@ -0,0 +1,47 @@
import { Service, Inject } from 'typedi';
import { Request, Response, Router, NextFunction } from 'express';
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))
);
return router;
}
/**
* Retrieve accounts types list.
* @param {Request} req - Request.
* @param {Response} res - Response.
* @return {Response}
*/
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);
}
}
}

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({ min: 1 }),
check('payment_methods.*.payment_integration_id').exists(),
check('payment_methods.*.enable').exists().isBoolean(),
];
}

View File

@@ -0,0 +1,55 @@
import { StripePaymentService } from '@/services/StripePayment/StripePaymentService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { CreateStripeAccountDTO } from './types';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
@Service()
export class CreateStripeAccountService {
@Inject()
private stripePaymentService: StripePaymentService;
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
/**
* Creates a new Stripe account.
* @param {number} tenantId
* @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 = {
...stripeAccountDTO,
name: 'Stripe',
};
// Stores the details of the Stripe account.
await PaymentIntegration.query().insert({
name: parsedStripeAccountDTO.name,
accountId: stripeAccountId,
enable: false,
service: 'Stripe',
});
// Triggers `onStripeIntegrationAccountCreated` event.
await this.eventPublisher.emitAsync(
events.stripeIntegration.onAccountCreated,
{
tenantId,
stripeAccountDTO,
stripeAccountId,
}
);
return stripeAccountId;
}
}

View File

@@ -0,0 +1,24 @@
import { Service, Inject } from 'typedi';
import { CreateStripeAccountService } from './CreateStripeAccountService';
import { CreateStripeAccountDTO } from './types';
@Service()
export class StripeIntegrationApplication {
@Inject()
private createStripeAccountService: CreateStripeAccountService;
/**
* Creates a new Stripe account for the tenant.
* @param {TenantContext} tenantContext - The tenant context.
* @param {string} label - The label for the Stripe account.
* @returns {Promise<string>} The ID of the created Stripe account.
*/
public async createStripeAccount(
tenantId: number,
stripeAccountDTO?: CreateStripeAccountDTO
): Promise<string> {
return this.createStripeAccountService.createStripeAccount(
tenantId,
stripeAccountDTO
);
}
}

View File

@@ -2,12 +2,16 @@ import { NextFunction, Request, Response, Router } from 'express';
import { Service, Inject } from 'typedi';
import { StripePaymentService } from '@/services/StripePayment/StripePaymentService';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import { StripeIntegrationApplication } from './StripeIntegrationApplication';
@Service()
export class StripeIntegrationController {
@Inject()
private stripePaymentService: StripePaymentService;
@Inject()
private stripeIntegrationApp: StripeIntegrationApplication;
router() {
const router = Router();
@@ -20,9 +24,19 @@ export class StripeIntegrationController {
}
public async createAccount(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
try {
const accountId = await this.stripePaymentService.createAccount();
res.status(201).json({ accountId });
const accountId = await this.stripeIntegrationApp.createStripeAccount(
tenantId
);
res
.status(201)
.json({
accountId,
message: 'The Stripe account has been created successfully.',
});
} catch (error) {
next(error);
}

View File

@@ -0,0 +1,6 @@
export interface CreateStripeAccountDTO {
name: string;
}

View File

@@ -6,9 +6,9 @@ exports.up = function (knex) {
return knex.schema.createTable('payment_integrations', (table) => {
table.increments('id');
table.string('service');
table.string('name');
table.string('name');
table.string('slug');
table.boolean('enable');
table.boolean('enable').defaultTo(true);
table.string('account_id');
table.json('options');
table.timestamps();

View File

@@ -7,8 +7,14 @@ exports.up = function (knex) {
table.increments('id');
table.integer('reference_id').unsigned();
table.string('reference_type');
table.integer('integration_id');
table.json('options');
table
.integer('payment_integration_id')
.unsigned()
.index()
.references('id')
.inTable('payment_integrations');
table.boolean('enable').defaultTo(false);
table.json('options').nullable();
});
};

View File

@@ -5,6 +5,24 @@ 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 ISaleInvoice {
id: number;
amount: number;
@@ -50,6 +68,8 @@ export interface ISaleInvoice {
invoiceMessage: string;
pdfTemplateId?: number;
paymentMethods?: Array<PaymentIntegrationTransactionLink>;
}
export interface ISaleInvoiceDTO {
@@ -223,7 +243,6 @@ export interface ISaleInvoiceMailSent {
messageOptions: SendInvoiceMailDTO;
}
// Invoice Pdf Document
export interface InvoicePdfLine {
item: string;
@@ -241,9 +260,9 @@ export interface InvoicePdfTax {
export interface InvoicePdfTemplateAttributes {
primaryColor: string;
secondaryColor: string;
companyName: string;
showCompanyLogo: boolean;
companyLogo: string;
@@ -301,4 +320,4 @@ export interface InvoicePdfTemplateAttributes {
billedToAddress: string[];
billedFromAddres: string[];
}
}

View File

@@ -0,0 +1,8 @@
export interface StripePaymentLinkCreatedEventPayload {
tenantId: number;
paymentLinkId: string;
saleInvoiceId: number;
stripeIntegrationId: number;
}

View File

@@ -120,6 +120,7 @@ import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/ev
import { TriggerInvalidateCacheOnSubscriptionChange } from '@/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange';
import { EventsTrackerListeners } from '@/services/EventsTracker/events/events';
import { CreatePaymentLinkOnInvoiceCreated } from '@/services/StripePayment/events/CreatePaymentLinkOnInvoiceCreated';
import { InvoicePaymentIntegrationSubscriber } from '@/services/Sales/Invoices/subscribers/InvoicePaymentIntegrationSubscriber';
export default () => {
return new EventPublisher();
@@ -293,7 +294,8 @@ export const susbcribers = () => {
SeedInitialDemoAccountDataOnOrgBuild,
// Stripe Payment
CreatePaymentLinkOnInvoiceCreated
CreatePaymentLinkOnInvoiceCreated,
InvoicePaymentIntegrationSubscriber,
...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

@@ -1,6 +1,7 @@
import { Model } from 'objection';
import TenantModel from 'models/TenantModel';
export class PaymentIntegration extends Model {
export class PaymentIntegration extends TenantModel {
static get tableName() {
return 'payment_integrations';
}
@@ -12,7 +13,7 @@ export class PaymentIntegration extends Model {
static get jsonSchema() {
return {
type: 'object',
required: ['service', 'enable'],
required: ['name', 'service', 'enable'],
properties: {
id: { type: 'integer' },
service: { type: 'string' },

View File

@@ -414,8 +414,8 @@ export default class SaleInvoice extends mixin(TenantModel, [
const Document = require('models/Document');
const { MatchedBankTransaction } = require('models/MatchedBankTransaction');
const {
TransactionPaymentService,
} = require('models/TransactionPaymentService');
TransactionPaymentServiceEntry,
} = require('models/TransactionPaymentServiceEntry');
return {
/**
@@ -577,14 +577,17 @@ export default class SaleInvoice extends mixin(TenantModel, [
},
/**
* Sale invoice may belongs to payment methods.
* Sale invoice may belongs to payment methods entries.
*/
paymentMethods: {
relation: Model.HasManyRelation,
modelClass: TransactionPaymentService,
modelClass: TransactionPaymentServiceEntry,
join: {
from: 'sales_invoices.id',
to: 'transactions_payment_services.referenceId',
to: 'transactions_payment_methods.referenceId',
},
beforeInsert: (model) => {
model.referenceType = 'SaleInvoice';
},
filter: (query) => {
query.where('reference_type', 'SaleInvoice');

View File

@@ -1,23 +1,25 @@
import { Model, mixin } from 'objection';
import TenantModel from 'models/TenantModel';
export class TransactionPaymentService extends TenantModel {
export class TransactionPaymentServiceEntry extends TenantModel {
/**
* Table name
*/
static get tableName() {
return 'transactions_payment_services';
return 'transactions_payment_methods';
}
/**
* Json schema of the model.
*/
static get jsonSchema() {
return {
type: 'object',
required: ['service', 'enable'],
required: ['paymentIntegrationId'],
properties: {
id: { type: 'integer' },
reference_id: { type: 'integer' },
reference_type: { type: 'string' },
service: { type: 'string' },
referenceId: { type: 'integer' },
referenceType: { type: 'string' },
paymentIntegrationId: { type: 'integer' },
enable: { type: 'boolean' },
options: { type: 'object' },
},

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('enable', true)
.orderBy('name', 'ASC');
return this.transform.transform(
tenantId,
paymentGateways,
new GetPaymentServicesSpecificInvoiceTransformer()
);
}
}

View File

@@ -0,0 +1,11 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetPaymentServicesSpecificInvoiceTransformer extends Transformer {
/**
* Exclude attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['accountId'];
};
}

View File

@@ -0,0 +1,20 @@
import { Service, Inject } from 'typedi';
import { GetPaymentServicesSpecificInvoice } from './GetPaymentServicesSpecificInvoice';
@Service()
export class PaymentServicesApplication {
@Inject()
private getPaymentServicesSpecificInvoice: GetPaymentServicesSpecificInvoice;
/**
* 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.
*/
async getPaymentServicesForInvoice(tenantId: number): Promise<any> {
return this.getPaymentServicesSpecificInvoice.getPaymentServicesInvoice(
tenantId
);
}
}

View File

@@ -0,0 +1,57 @@
import { Service, Inject } from 'typedi';
import { omit } from 'lodash';
import events from '@/subscribers/events';
import {
ISaleInvoiceCreatedPayload,
PaymentIntegrationTransactionLink,
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
);
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
);
}
);
};
}

View File

@@ -1,8 +1,11 @@
import { Inject, Service } from 'typedi';
import { ISaleInvoice } from '@/interfaces';
import { StripePaymentService } from './StripePaymentService';
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { StripePaymentLinkCreatedEventPayload } from '@/interfaces/StripePayment';
import { STRIPE_PAYMENT_LINK_REDIRECT } from './constants';
import events from '@/subscribers/events';
@Service()
export class SaleInvoiceStripePaymentLink {
@@ -12,48 +15,131 @@ export class SaleInvoiceStripePaymentLink {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
/**
* Creates a Stripe payment link for the given sale invoice.
* @param {number} tenantId
* @param {ISaleInvoice} saleInvoice
* @param {number} tenantId - Tenant id.
* @param {number} stripeIntegrationId - Stripe integration id.
* @param {ISaleInvoice} saleInvoice - Sale invoice id.
* @returns {Promise<string>}
*/
async createPaymentLink(tenantId: number, saleInvoice: ISaleInvoice) {
const { SaleInvoice } = this.tenancy.models(tenantId);
const saleInvoiceId = saleInvoice.id;
async createPaymentLink(
tenantId: number,
stripeIntegrationId: number,
invoiceId: number
) {
const { SaleInvoice, PaymentIntegration } = this.tenancy.models(tenantId);
try {
const stripeAcocunt = { stripeAccount: 'acct_1Px3dSPjeOqFxnPw' };
const price = await this.stripePayment.stripe.prices.create(
{
unit_amount: saleInvoice.total * 100,
currency: 'usd',
product_data: {
name: saleInvoice.invoiceNo,
},
const stripeIntegration = await PaymentIntegration.query()
.findById(stripeIntegrationId)
.throwIfNotFound();
const stripeAccountId = stripeIntegration.accountId;
const invoice = await SaleInvoice.query()
.findById(invoiceId)
.throwIfNotFound();
// Creates Stripe price.
const price = await this.createStripePrice(invoice, stripeAccountId);
// Creates Stripe payment link.
const paymentLink = await this.createStripePaymentLink(
price.id,
invoice,
stripeAccountId,
{ tenantId }
);
// Associate the payment link id to the invoice.
await this.updateInvoiceWithPaymentLink(
tenantId,
invoiceId,
paymentLink.id
);
// Triggers `onStripePaymentLinkCreated` event.
await this.eventPublisher.emitAsync(
events.stripeIntegration.onPaymentLinkCreated,
{
tenantId,
stripeIntegrationId,
saleInvoiceId: invoiceId,
paymentLinkId: paymentLink.id,
} as StripePaymentLinkCreatedEventPayload
);
return paymentLink.id;
}
/**
* Creates a Stripe price for the invoice.
* @param {ISaleInvoice} invoice - Sale invoice.
* @param {string} stripeAccountId - Stripe account id.
* @returns {Promise<Stripe.Price>}
*/
private async createStripePrice(
invoice: ISaleInvoice,
stripeAccountId: string
) {
return this.stripePayment.stripe.prices.create(
{
unit_amount: invoice.total * 100,
currency: 'usd',
product_data: {
name: invoice.invoiceNo,
},
stripeAcocunt
);
const paymentLinkInfo = {
line_items: [{ price: price.id, quantity: 1 }],
after_completion: {
type: 'redirect',
redirect: {
url: STRIPE_PAYMENT_LINK_REDIRECT,
},
},
{ stripeAccount: stripeAccountId }
);
}
/**
* Creates a Stripe payment link.
* @param {string} priceId - Stripe price id.
* @param {ISaleInvoice} invoice - Sale invoice.
* @param {number} tenantId - Tenant id.
* @param {string} stripeAccountId - Stripe account id.
* @returns {Promise<Stripe.PaymentLink>}
*/
private async createStripePaymentLink(
priceId: string,
invoice: ISaleInvoice,
stripeAccountId: string,
metadata: Record<string, any> = {}
) {
const paymentLinkInfo = {
line_items: [{ price: priceId, quantity: 1 }],
after_completion: {
type: 'redirect',
redirect: {
url: STRIPE_PAYMENT_LINK_REDIRECT,
},
metadata: { saleInvoiceId, tenantId, resource: 'SaleInvoice' },
};
const paymentLink = await this.stripePayment.stripe.paymentLinks.create(
paymentLinkInfo,
stripeAcocunt
);
await SaleInvoice.query().findById(saleInvoiceId).patch({
stripePlinkId: paymentLink.id,
});
return paymentLink.id;
} catch (error) {
console.error('Error creating payment link:', error);
}
},
metadata: {
saleInvoiceId: invoice.id,
resource: 'SaleInvoice',
...metadata,
},
};
return this.stripePayment.stripe.paymentLinks.create(paymentLinkInfo, {
stripeAccount: stripeAccountId,
});
}
/**
* Updates the sale invoice with the Stripe payment link id.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Sale invoice id.
* @param {string} paymentLinkId - Stripe payment link id.
*/
private async updateInvoiceWithPaymentLink(
tenantId: number,
invoiceId: number,
paymentLinkId: string
) {
const { SaleInvoice } = this.tenancy.models(tenantId);
await SaleInvoice.query().findById(invoiceId).patch({
stripePlinkId: paymentLinkId,
});
}
}

View File

@@ -1,8 +1,8 @@
import { Inject, Service } from 'typedi';
import { EventSubscriber } from '@/lib/EventPublisher/EventPublisher';
import {
ISaleInvoiceCreatedPayload,
ISaleInvoiceDeletedPayload,
PaymentIntegrationTransactionLinkEventPayload,
} from '@/interfaces';
import { SaleInvoiceStripePaymentLink } from '../SaleInvoiceStripePaymentLink';
import { runAfterTransaction } from '@/services/UnitOfWork/TransactionsHooks';
@@ -22,29 +22,35 @@ export class CreatePaymentLinkOnInvoiceCreated extends EventSubscriber {
*/
public attach(bus) {
bus.subscribe(
events.saleInvoice.onCreated,
this.handleUpdateTransactionsOnItemCreated
);
bus.subscribe(
events.saleInvoice.onDeleted,
this.handleDeletePaymentLinkOnInvoiceDeleted
events.paymentIntegrationLink.onPaymentIntegrationLink,
this.handleCreatePaymentLinkOnIntegrationLink
);
// bus.subscribe(
// events.saleInvoice.onDeleted,
// this.handleDeletePaymentLinkOnInvoiceDeleted
// );
}
/**
* Updates the Plaid item transactions
* @param {IPlaidItemCreatedEventPayload} payload - Event payload.
*/
private handleUpdateTransactionsOnItemCreated = async ({
saleInvoice,
saleInvoiceId,
private handleCreatePaymentLinkOnIntegrationLink = async ({
tenantId,
paymentIntegrationId,
referenceId,
referenceType,
trx,
}: ISaleInvoiceCreatedPayload) => {
}: PaymentIntegrationTransactionLinkEventPayload) => {
// Can't continue if the link request is not coming from the invoice transaction.
if ('SaleInvoice' !== referenceType) {
return;
}
runAfterTransaction(trx, async () => {
await this.invoiceStripePaymentLink.createPaymentLink(
tenantId,
saleInvoice
paymentIntegrationId,
referenceId
);
});
};
@@ -61,6 +67,5 @@ export class CreatePaymentLinkOnInvoiceCreated extends EventSubscriber {
tenantId,
saleInvoiceId
);
};
}

View File

@@ -702,4 +702,17 @@ export default {
onAssignedDefault: 'onPdfTemplateAssignedDefault',
onAssigningDefault: 'onPdfTemplateAssigningDefault',
},
// Payment methods integrations
paymentIntegrationLink: {
onPaymentIntegrationLink: 'onPaymentIntegrationLink'
},
// Stripe Payment Integration
stripeIntegration: {
onAccountCreated: 'onStripeIntegrationAccountCreated',
onAccountDeleted: 'onStripeIntegrationAccountDeleted',
onPaymentLinkCreated: 'onStripePaymentLinkCreated',
}
};