diff --git a/packages/server/package.json b/packages/server/package.json index 065700a0b..7cc0e8664 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -115,6 +115,7 @@ "tsyringe": "^4.3.0", "typedi": "^0.8.0", "uniqid": "^5.2.0", + "uuid": "^10.0.0", "winston": "^3.2.1", "xlsx": "^0.18.5", "yup": "^0.28.1" diff --git a/packages/server/src/api/controllers/ShareLink/PublicSharableLinkController.ts b/packages/server/src/api/controllers/ShareLink/PublicSharableLinkController.ts new file mode 100644 index 000000000..91590d6a9 --- /dev/null +++ b/packages/server/src/api/controllers/ShareLink/PublicSharableLinkController.ts @@ -0,0 +1,51 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express'; +import { body, param } from 'express-validator'; +import BaseController from '@/api/controllers/BaseController'; +import { GetInvoicePaymentLinkMetadata } from '@/services/Sales/Invoices/GetInvoicePaymentLinkMetadata'; + +@Service() +export class PublicSharableLinkController extends BaseController { + @Inject() + private getSharableLinkMetaService: GetInvoicePaymentLinkMetadata; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/sharable-links/meta/invoice/:linkId', + [param('linkId').exists()], + this.validationResult, + this.getPaymentLinkPublicMeta.bind(this), + this.validationResult + ); + return router; + } + + /** + * Retrieves the payment link public meta. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns + */ + public async getPaymentLinkPublicMeta( + req: Request, + res: Response, + next: NextFunction + ) { + const { linkId } = req.params; + + try { + const data = + await this.getSharableLinkMetaService.getInvoicePaymentLinkMeta(linkId); + + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/ShareLink/ShareLinkController.ts b/packages/server/src/api/controllers/ShareLink/ShareLinkController.ts new file mode 100644 index 000000000..52065924f --- /dev/null +++ b/packages/server/src/api/controllers/ShareLink/ShareLinkController.ts @@ -0,0 +1,65 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express'; +import { body } from 'express-validator'; +import { AbilitySubject, PaymentReceiveAction } from '@/interfaces'; +import BaseController from '@/api/controllers/BaseController'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; +import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { GenerateShareLink } from '@/services/Sales/Invoices/GenerateeInvoicePaymentLink'; + +@Service() +export class ShareLinkController extends BaseController { + @Inject() + private generateShareLinkService: GenerateShareLink; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.post( + '/payment-links/generate', + CheckPolicies(PaymentReceiveAction.Edit, AbilitySubject.PaymentReceive), + [ + body('transaction_type').exists(), + body('transaction_id').exists().isNumeric().toInt(), + body('publicity').optional(), + body('expiry_date').optional({ nullable: true }), + ], + this.validationResult, + asyncMiddleware(this.generateShareLink.bind(this)) + ); + + return router; + } + + /** + * Generates sharable link for the given transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public async generateShareLink( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { transactionType, transactionId, publicity, expiryDate } = + this.matchedBodyData(req); + + try { + const link = await this.generateShareLinkService.generatePaymentLink( + tenantId, + transactionId, + transactionType, + publicity, + expiryDate + ); + res.status(200).json({ link }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts b/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts index 31273943c..cbd45953c 100644 --- a/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts +++ b/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts @@ -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 diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index 38ca5a357..2eed58224 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -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()); diff --git a/packages/server/src/database/migrations/20240915155403_creat_transaction_payment_service_table.js b/packages/server/src/database/migrations/20240915155403_creat_transaction_payment_service_table.js new file mode 100644 index 000000000..d56754965 --- /dev/null +++ b/packages/server/src/database/migrations/20240915155403_creat_transaction_payment_service_table.js @@ -0,0 +1,21 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('transactions_payment_methods', (table) => { + table.increments('id'); + table.integer('reference_id').unsigned(); + table.string('reference_type'); + table.integer('integration_id'); + table.json('options'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('transactions_payment_methods'); +}; diff --git a/packages/server/src/database/migrations/20240915163722_payment_integration.js b/packages/server/src/database/migrations/20240915163722_payment_integration.js new file mode 100644 index 000000000..bff641454 --- /dev/null +++ b/packages/server/src/database/migrations/20240915163722_payment_integration.js @@ -0,0 +1,24 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('payment_integrations', (table) => { + table.increments('id'); + table.string('service'); + table.string('name'); + table.string('slug'); + table.boolean('enable'); + table.string('account_id'); + table.json('options'); + table.timestamps(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('payment_integrations'); +}; diff --git a/packages/server/src/models/PaymentIntegration.ts b/packages/server/src/models/PaymentIntegration.ts new file mode 100644 index 000000000..a84a2d305 --- /dev/null +++ b/packages/server/src/models/PaymentIntegration.ts @@ -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' }, + }, + }; + } +} diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts index 41cc528ff..53803620e 100644 --- a/packages/server/src/models/SaleInvoice.ts +++ b/packages/server/src/models/SaleInvoice.ts @@ -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'); + }, + }, }; } diff --git a/packages/server/src/models/TransactionPaymentService.ts b/packages/server/src/models/TransactionPaymentService.ts new file mode 100644 index 000000000..5025d6683 --- /dev/null +++ b/packages/server/src/models/TransactionPaymentService.ts @@ -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 {}; + } +} diff --git a/packages/server/src/services/Sales/Invoices/GeneratePaymentLinkTransformer.ts b/packages/server/src/services/Sales/Invoices/GeneratePaymentLinkTransformer.ts new file mode 100644 index 000000000..70338e181 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/GeneratePaymentLinkTransformer.ts @@ -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}`; + } +} diff --git a/packages/server/src/services/Sales/Invoices/GenerateeInvoicePaymentLink.ts b/packages/server/src/services/Sales/Invoices/GenerateeInvoicePaymentLink.ts new file mode 100644 index 000000000..7063dee98 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/GenerateeInvoicePaymentLink.ts @@ -0,0 +1,85 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { v4 as uuidv4 } from 'uuid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { PaymentLink } from '@/system/models'; +import { GeneratePaymentLinkTransformer } from './GeneratePaymentLinkTransformer'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; + +@Service() +export class GenerateShareLink { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Generates private or public payment link for the given sale invoice. + * @param {number} tenantId - Tenant id. + * @param {number} invoiceId - Sale invoice id. + * @param {string} publicOrPrivate - Public or private. + * @param {string} expiryTime - Expiry time. + */ + async generatePaymentLink( + tenantId: number, + transactionId: number, + transactionType: string, + publicity: string = 'private', + expiryTime: string = '' + ) { + const { SaleInvoice } = this.tenancy.models(tenantId); + + const foundInvoice = await SaleInvoice.query() + .findById(transactionId) + .throwIfNotFound(); + + // Generate unique uuid for sharable link. + const linkId = uuidv4() as string; + + const commonEventPayload = { + tenantId, + transactionId, + transactionType, + publicity, + expiryTime, + }; + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onPublicSharableLinkGenerating` event. + await this.eventPublisher.emitAsync( + events.saleInvoice.onPublicLinkGenerating, + { ...commonEventPayload, trx } + ); + const paymentLink = await PaymentLink.query().insert({ + linkId, + tenantId, + publicity, + resourceId: foundInvoice.id, + resourceType: 'SaleInvoice', + }); + // Triggers `onPublicSharableLinkGenerated` event. + await this.eventPublisher.emitAsync( + events.saleInvoice.onPublicLinkGenerated, + { + ...commonEventPayload, + paymentLink, + trx, + } + ); + return this.transformer.transform( + tenantId, + paymentLink, + new GeneratePaymentLinkTransformer() + ); + }); + } +} diff --git a/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkMetadata.ts b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkMetadata.ts new file mode 100644 index 000000000..740b13d37 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkMetadata.ts @@ -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() + ); + } +} diff --git a/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts new file mode 100644 index 000000000..39909a805 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/GetInvoicePaymentLinkTransformer.ts @@ -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.'; + } +} diff --git a/packages/server/src/services/StripePayment/CreateStripeAccountService.ts b/packages/server/src/services/StripePayment/CreateStripeAccountService.ts new file mode 100644 index 000000000..b3209fd23 --- /dev/null +++ b/packages/server/src/services/StripePayment/CreateStripeAccountService.ts @@ -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, + }); + } +} diff --git a/packages/server/src/services/StripePayment/StripePaymentService.ts b/packages/server/src/services/StripePayment/StripePaymentService.ts index bcb03659c..a08ce122d 100644 --- a/packages/server/src/services/StripePayment/StripePaymentService.ts +++ b/packages/server/src/services/StripePayment/StripePaymentService.ts @@ -33,11 +33,15 @@ export class StripePaymentService { } } + /** + * + * @returns {Promise} + */ public async createAccount(): Promise { 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' diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index d21ffac98..ce6a5a46b 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -163,6 +163,9 @@ export default { onMailReminderSend: 'onSaleInvoiceMailReminderSend', onMailReminderSent: 'onSaleInvoiceMailReminderSent', + + onPublicLinkGenerating: 'onPublicSharableLinkGenerating', + onPublicLinkGenerated: 'onPublicSharableLinkGenerated', }, /** diff --git a/packages/server/src/system/migrations/20240915070439_create_payment_links_table.js b/packages/server/src/system/migrations/20240915070439_create_payment_links_table.js new file mode 100644 index 000000000..1283052c6 --- /dev/null +++ b/packages/server/src/system/migrations/20240915070439_create_payment_links_table.js @@ -0,0 +1,24 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('payment_links', (table) => { + table.increments('id'); + table.integer('tenant_id'); + table.integer('resource_id'); + table.text('resource_type'); + table.string('linkId'); + table.string('publicity'); + table.datetime('expiry_at'); + table.timestamps(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('payment_links'); +}; diff --git a/packages/server/src/system/models/PaymentLink.ts b/packages/server/src/system/models/PaymentLink.ts new file mode 100644 index 000000000..00d83ea2d --- /dev/null +++ b/packages/server/src/system/models/PaymentLink.ts @@ -0,0 +1,26 @@ +import { Model } from 'objection'; + +export class PaymentLink extends Model { + static get tableName() { + return 'payment_links'; + } + + /** + * Timestamps columns. + * @returns {string[]} + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + public tenantId!: number; + public resourceId!: number; + public resourceType!: string; + public linkId!: string; + public publicity!: string; + public expiryAt!: Date; + + // Timestamps + public createdAt!: Date; + public updatedAt!: Date; +} diff --git a/packages/server/src/system/models/index.ts b/packages/server/src/system/models/index.ts index e753e081c..05dd23f87 100644 --- a/packages/server/src/system/models/index.ts +++ b/packages/server/src/system/models/index.ts @@ -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, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dd88c77a..534d31b78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,9 @@ importers: uniqid: specifier: ^5.2.0 version: 5.4.0 + uuid: + specifier: ^10.0.0 + version: 10.0.0 winston: specifier: ^3.2.1 version: 3.13.0 @@ -25387,6 +25390,11 @@ packages: engines: {node: '>= 0.4.0'} dev: false + /uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + dev: false + /uuid@3.4.0: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.