feat: Link transations with payment methods

This commit is contained in:
Ahmed Bouhuolia
2024-09-15 19:42:43 +02:00
parent 542e61dbfc
commit 430cf19533
21 changed files with 581 additions and 8 deletions

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

@@ -45,7 +45,6 @@ export class StripeWebhooksController {
config.stripePayment.webhooksSecret
);
} catch (err) {
console.log(err);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event based on its type

View File

@@ -65,6 +65,8 @@ 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';
export default () => {
const app = Router();
@@ -82,7 +84,8 @@ export default () => {
app.use('/jobs', Container.get(Jobs).router());
app.use('/account', Container.get(Account).router());
app.use('/webhooks', Container.get(Webhooks).router());
app.use('/demo', Container.get(OneClickDemoController).router())
app.use('/demo', Container.get(OneClickDemoController).router());
app.use(Container.get(PublicSharableLinkController).router());
// - Dashboard routes.
// ---------------------------
@@ -148,11 +151,14 @@ 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(
'/stripe_integration',
Container.get(StripeIntegrationController).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

@@ -0,0 +1,21 @@
/**
* @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('integration_id');
table.json('options');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('transactions_payment_methods');
};

View File

@@ -0,0 +1,24 @@
/**
* @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('enable');
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 @@
import { Model } from 'objection';
export class PaymentIntegration extends Model {
static get tableName() {
return 'payment_integrations';
}
static get idColumn() {
return 'id';
}
static get jsonSchema() {
return {
type: 'object',
required: ['service', 'enable'],
properties: {
id: { type: 'integer' },
service: { type: 'string' },
enable: { type: 'boolean' },
accountId: { type: 'string' },
options: { type: 'object' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
};
}
}

View File

@@ -413,6 +413,9 @@ export default class SaleInvoice extends mixin(TenantModel, [
const TaxRateTransaction = require('models/TaxRateTransaction');
const Document = require('models/Document');
const { MatchedBankTransaction } = require('models/MatchedBankTransaction');
const {
TransactionPaymentService,
} = require('models/TransactionPaymentService');
return {
/**
@@ -509,7 +512,7 @@ export default class SaleInvoice extends mixin(TenantModel, [
join: {
from: 'sales_invoices.warehouseId',
to: 'warehouses.id',
}
},
},
/**
@@ -566,12 +569,27 @@ 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.
*/
paymentMethods: {
relation: Model.HasManyRelation,
modelClass: TransactionPaymentService,
join: {
from: 'sales_invoices.id',
to: 'transactions_payment_services.referenceId',
},
filter: (query) => {
query.where('reference_type', 'SaleInvoice');
},
},
};
}

View File

@@ -0,0 +1,33 @@
import { Model, mixin } from 'objection';
import TenantModel from 'models/TenantModel';
export class TransactionPaymentService extends TenantModel {
/**
* Table name
*/
static get tableName() {
return 'transactions_payment_services';
}
static get jsonSchema() {
return {
type: 'object',
required: ['service', 'enable'],
properties: {
id: { type: 'integer' },
reference_id: { type: 'integer' },
reference_type: { type: 'string' },
service: { type: 'string' },
enable: { type: 'boolean' },
options: { type: 'object' },
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
return {};
}
}

View File

@@ -0,0 +1,28 @@
import { Transformer } from '@/lib/Transformer/Transformer';
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'];
};
/**
*
* @param link
* @returns
*/
public link(link) {
return `http://localhost:3000/payment/${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,59 @@
import moment from 'moment';
import { ServiceError } from '@/exceptions';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { PaymentLink } from '@/system/models';
import { Inject, Service } from 'typedi';
import { GeneratePaymentLinkTransformer } from './GeneratePaymentLinkTransformer';
import { GetInvoicePaymentLinkMetaTransformer } from './GetInvoicePaymentLinkTransformer';
import { initalizeTenantServices } from '@/api/middleware/TenantDependencyInjection';
@Service()
export class GetInvoicePaymentLinkMetadata {
@Inject()
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)
.throwIfNotFound();
//
if (paymentLink.resourceType !== 'SaleInvoice') {
throw new ServiceError('');
}
// 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')
.withGraphFetched('customer')
.throwIfNotFound();
return this.transformer.transform(
tenantId,
invoice,
new GetInvoicePaymentLinkMetaTransformer()
);
}
}

View File

@@ -0,0 +1,46 @@
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',
];
};
public customerName(invoice) {
return invoice.customer.displayName;
}
public companyName() {
return 'Bigcapital Technology, Inc.';
}
}

View File

@@ -0,0 +1,43 @@
import { Inject, Service } from 'typedi';
import { StripePaymentService } from './StripePaymentService';
import HasTenancyService from '../Tenancy/TenancyService';
import { snakeCase } from 'lodash';
interface CreateStripeAccountDTO {
name: string;
}
@Service()
export class CreateStripeAccountService {
@Inject()
private stripePaymentService: StripePaymentService;
@Inject()
private tenancy: HasTenancyService;
/**
* Creates a new Stripe account for Bigcapital.
* @param {number} tenantId
* @param {number} createStripeAccountDTO
*/
async createAccount(
tenantId: number,
createStripeAccountDTO: CreateStripeAccountDTO
) {
const { PaymentIntegration } = this.tenancy.models(tenantId);
// Creates a new Stripe account.
const account = await this.stripePaymentService.createAccount();
const slug = snakeCase(createStripeAccountDTO.name);
// Store the Stripe account on tenant store.
await PaymentIntegration.query().insert({
service: 'stripe',
name: createStripeAccountDTO.name,
slug,
enable: true,
accountId: account.id,
});
}
}

View File

@@ -33,11 +33,15 @@ export class StripePaymentService {
}
}
/**
*
* @returns {Promise<string>}
*/
public async createAccount(): Promise<string> {
try {
const account = await this.stripe.accounts.create({});
return account.id;
return account;
} catch (error) {
throw new Error(
'An error occurred when calling the Stripe API to create an account'

View File

@@ -163,6 +163,9 @@ export default {
onMailReminderSend: 'onSaleInvoiceMailReminderSend',
onMailReminderSent: 'onSaleInvoiceMailReminderSent',
onPublicLinkGenerating: 'onPublicSharableLinkGenerating',
onPublicLinkGenerated: 'onPublicSharableLinkGenerated',
},
/**

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

@@ -8,6 +8,7 @@ import Invite from './Invite';
import SystemPlaidItem from './SystemPlaidItem';
import { Import } from './Import';
import { StripeAccount } from './StripeAccount';
import { PaymentLink } from './PaymentLink';
export {
Plan,
@@ -19,5 +20,6 @@ export {
Invite,
SystemPlaidItem,
Import,
StripeAccount
StripeAccount,
PaymentLink,
};