diff --git a/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts b/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts new file mode 100644 index 000000000..31273943c --- /dev/null +++ b/packages/server/src/api/controllers/StripeIntegration/StripeWebhooksController.ts @@ -0,0 +1,84 @@ +import { StripePaymentService } from '@/services/StripePayment/StripePaymentService'; +import { NextFunction, Request, Response, Router } from 'express'; +import { Inject, Service } from 'typedi'; +import config from '@/config'; +import bodyParser from 'body-parser'; +import { SaleInvoiceStripePaymentLink } from '@/services/StripePayment/SaleInvoiceStripePaymentLink'; +import { CreatePaymentReceiveStripePayment } from '@/services/StripePayment/CreatePaymentReceivedStripePayment'; + +@Service() +export class StripeWebhooksController { + @Inject() + private stripePaymentService: StripePaymentService; + + @Inject() + private createPaymentReceiveStripePayment: CreatePaymentReceiveStripePayment; + + router() { + const router = Router(); + + router.post( + '/stripe', + bodyParser.raw({ type: 'application/json' }), + this.handleWebhook.bind(this) + ); + return router; + } + + /** + * + * @param req + * @param res + * @param next + */ + public 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) { + console.log(err); + return res.status(400).send(`Webhook Error: ${err.message}`); + } + // Handle the event based on its type + switch (event.type) { + case 'checkout.session.completed': + 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; + + await this.createPaymentReceiveStripePayment.createPaymentReceived( + tenantId, + saleInvoiceId, + amountInDollars + ); + break; + case 'payment_intent.payment_failed': + // Handle failed payment intent + console.log('PaymentIntent failed.'); + break; + // Add more cases as needed + default: + console.log(`Unhandled event type ${event.type}`); + } + + res.status(200).json({ received: true }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Webhooks/Webhooks.ts b/packages/server/src/api/controllers/Webhooks/Webhooks.ts index 3ccbc6d8c..1659f0a9d 100644 --- a/packages/server/src/api/controllers/Webhooks/Webhooks.ts +++ b/packages/server/src/api/controllers/Webhooks/Webhooks.ts @@ -1,9 +1,10 @@ import { NextFunction, Router, Request, Response } from 'express'; -import { Inject, Service } from 'typedi'; +import Container, { Inject, Service } from 'typedi'; import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication'; import BaseController from '../BaseController'; import { LemonSqueezyWebhooks } from '@/services/Subscription/LemonSqueezyWebhooks'; import { PlaidWebhookTenantBootMiddleware } from '@/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware'; +import { StripeWebhooksController } from '../StripeIntegration/StripeWebhooksController'; @Service() export class Webhooks extends BaseController { @@ -24,6 +25,8 @@ export class Webhooks extends BaseController { router.post('/lemon', this.lemonWebhooks.bind(this)); + router.use(Container.get(StripeWebhooksController).router()); + return router; } diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index f79a36e54..37c5e9225 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -268,5 +268,6 @@ module.exports = { stripePayment: { secretKey: process.env.STRIPE_PAYMENT_SECRET_KEY || '', publishableKey: process.env.STRIPE_PAYMENT_PUBLISHABLE_KEY || '', + webhooksSecret: process.env.STRIPE_PAYMENT_WEBHOOKS_SECRET || '', }, }; diff --git a/packages/server/src/database/migrations/20240909093332_add_stripe_plink_id_to_invoices_table.js b/packages/server/src/database/migrations/20240909093332_add_stripe_plink_id_to_invoices_table.js new file mode 100644 index 000000000..b420d2e58 --- /dev/null +++ b/packages/server/src/database/migrations/20240909093332_add_stripe_plink_id_to_invoices_table.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.table('sales_invoices', (table) => { + table.string('stripe_plink_id').nullable(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.table('sales_invoices', (table) => { + table.dropColumn('stripe_plink_id'); + }); +}; diff --git a/packages/server/src/database/migrations/20240909101051_add_stripe_pintent_id_to_payments_received.js b/packages/server/src/database/migrations/20240909101051_add_stripe_pintent_id_to_payments_received.js new file mode 100644 index 000000000..5a82f16bb --- /dev/null +++ b/packages/server/src/database/migrations/20240909101051_add_stripe_pintent_id_to_payments_received.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.table('payment_receives', (table) => { + table.string('stripe_pintent_id').nullable(); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.table('payment_receives', (table) => { + table.dropColumn('stripe_pintent_id'); + }); +}; diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 967e43f57..29851a084 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -119,6 +119,7 @@ import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Ban import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData'; import { TriggerInvalidateCacheOnSubscriptionChange } from '@/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange'; import { EventsTrackerListeners } from '@/services/EventsTracker/events/events'; +import { CreatePaymentLinkOnInvoiceCreated } from '@/services/StripePayment/events/CreatePaymentLinkOnInvoiceCreated'; export default () => { return new EventPublisher(); @@ -291,6 +292,9 @@ export const susbcribers = () => { // Demo Account SeedInitialDemoAccountDataOnOrgBuild, + // Stripe Payment + CreatePaymentLinkOnInvoiceCreated + ...EventsTrackerListeners ]; }; diff --git a/packages/server/src/services/StripePayment/CreatePaymentReceivedStripePayment.ts b/packages/server/src/services/StripePayment/CreatePaymentReceivedStripePayment.ts new file mode 100644 index 000000000..28158ebb1 --- /dev/null +++ b/packages/server/src/services/StripePayment/CreatePaymentReceivedStripePayment.ts @@ -0,0 +1,37 @@ +import { Inject, Service } from 'typedi'; +import { GetSaleInvoice } from '../Sales/Invoices/GetSaleInvoice'; +import { CreatePaymentReceived } from '../Sales/PaymentReceived/CreatePaymentReceived'; + +@Service() +export class CreatePaymentReceiveStripePayment { + @Inject() + private getSaleInvoiceService: GetSaleInvoice; + + @Inject() + private createPaymentReceivedService: CreatePaymentReceived; + + /** + * + * @param {number} tenantId + * @param {number} saleInvoiceId + * @param {number} paidAmount + */ + async createPaymentReceived( + tenantId: number, + saleInvoiceId: number, + paidAmount: number + ) { + const invoice = await this.getSaleInvoiceService.getSaleInvoice( + tenantId, + saleInvoiceId + ); + await this.createPaymentReceivedService.createPaymentReceived(tenantId, { + customerId: invoice.customerId, + paymentDate: new Date(), + amount: paidAmount, + depositAccountId: 1002, + statement: '', + entries: [{ invoiceId: saleInvoiceId, paymentAmount: paidAmount }], + }); + } +} diff --git a/packages/server/src/services/StripePayment/DeleteStripePaymentLinkInvoice.ts b/packages/server/src/services/StripePayment/DeleteStripePaymentLinkInvoice.ts new file mode 100644 index 000000000..68c45387a --- /dev/null +++ b/packages/server/src/services/StripePayment/DeleteStripePaymentLinkInvoice.ts @@ -0,0 +1,37 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { StripePaymentService } from './StripePaymentService'; +import { Knex } from 'knex'; + +@Service() +export class DeleteStripePaymentLinkInvoice { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private stripePayment: StripePaymentService; + + /** + * Deletes the Stripe payment link associates to the given sale invoice. + * @param {number} tenantId + * @param {number} invoiceId + */ + async deletePaymentLink( + tenantId: number, + invoiceId: number, + trx?: Knex.Transaction + ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + const invoice = await SaleInvoice.query().findById(invoiceId); + + const stripeAcocunt = { stripeAccount: 'acct_1Px3dSPjeOqFxnPw' }; + + if (invoice.stripePlinkId) { + await this.stripePayment.stripe.paymentLinks.update( + invoice.stripePlinkId, + { active: false }, + stripeAcocunt + ); + } + } +} diff --git a/packages/server/src/services/StripePayment/SaleInvoiceStripePaymentLink.ts b/packages/server/src/services/StripePayment/SaleInvoiceStripePaymentLink.ts new file mode 100644 index 000000000..bf3c875d8 --- /dev/null +++ b/packages/server/src/services/StripePayment/SaleInvoiceStripePaymentLink.ts @@ -0,0 +1,59 @@ +import { ISaleInvoice } from '@/interfaces'; +import { StripePaymentService } from './StripePaymentService'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { STRIPE_PAYMENT_LINK_REDIRECT } from './constants'; + +@Service() +export class SaleInvoiceStripePaymentLink { + @Inject() + private stripePayment: StripePaymentService; + + @Inject() + private tenancy: HasTenancyService; + + /** + * Creates a Stripe payment link for the given sale invoice. + * @param {number} tenantId + * @param {ISaleInvoice} saleInvoice + * @returns {Promise} + */ + async createPaymentLink(tenantId: number, saleInvoice: ISaleInvoice) { + const { SaleInvoice } = this.tenancy.models(tenantId); + const saleInvoiceId = saleInvoice.id; + + 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, + }, + }, + stripeAcocunt + ); + const paymentLinkInfo = { + line_items: [{ price: price.id, 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); + } + } +} diff --git a/packages/server/src/services/StripePayment/StripePaymentService.ts b/packages/server/src/services/StripePayment/StripePaymentService.ts index 254298ed1..bcb03659c 100644 --- a/packages/server/src/services/StripePayment/StripePaymentService.ts +++ b/packages/server/src/services/StripePayment/StripePaymentService.ts @@ -4,7 +4,7 @@ import config from '@/config'; @Service() export class StripePaymentService { - private stripe; + public stripe; constructor() { this.stripe = new stripe(config.stripePayment.secretKey, { @@ -12,7 +12,12 @@ export class StripePaymentService { }); } - public async createAccountSession(accountId: string) { + /** + * + * @param {number} accountId + * @returns {Promise} + */ + public async createAccountSession(accountId: string): Promise { try { const accountSession = await this.stripe.accountSessions.create({ account: accountId, @@ -28,7 +33,7 @@ export class StripePaymentService { } } - public async createAccount() { + public async createAccount(): Promise { try { const account = await this.stripe.accounts.create({}); diff --git a/packages/server/src/services/StripePayment/constants.ts b/packages/server/src/services/StripePayment/constants.ts new file mode 100644 index 000000000..c08967f19 --- /dev/null +++ b/packages/server/src/services/StripePayment/constants.ts @@ -0,0 +1 @@ +export const STRIPE_PAYMENT_LINK_REDIRECT = 'https://your_redirect_url.com'; diff --git a/packages/server/src/services/StripePayment/events/CreatePaymentLinkOnInvoiceCreated.tsx b/packages/server/src/services/StripePayment/events/CreatePaymentLinkOnInvoiceCreated.tsx new file mode 100644 index 000000000..971e86d9f --- /dev/null +++ b/packages/server/src/services/StripePayment/events/CreatePaymentLinkOnInvoiceCreated.tsx @@ -0,0 +1,66 @@ +import { Inject, Service } from 'typedi'; +import { EventSubscriber } from '@/lib/EventPublisher/EventPublisher'; +import { + ISaleInvoiceCreatedPayload, + ISaleInvoiceDeletedPayload, +} from '@/interfaces'; +import { SaleInvoiceStripePaymentLink } from '../SaleInvoiceStripePaymentLink'; +import { runAfterTransaction } from '@/services/UnitOfWork/TransactionsHooks'; +import events from '@/subscribers/events'; +import { DeleteStripePaymentLinkInvoice } from '../DeleteStripePaymentLinkInvoice'; + +@Service() +export class CreatePaymentLinkOnInvoiceCreated extends EventSubscriber { + @Inject() + private invoiceStripePaymentLink: SaleInvoiceStripePaymentLink; + + @Inject() + private deleteStripePaymentLinkInvoice: DeleteStripePaymentLinkInvoice; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.saleInvoice.onCreated, + this.handleUpdateTransactionsOnItemCreated + ); + bus.subscribe( + events.saleInvoice.onDeleted, + this.handleDeletePaymentLinkOnInvoiceDeleted + ); + } + + /** + * Updates the Plaid item transactions + * @param {IPlaidItemCreatedEventPayload} payload - Event payload. + */ + private handleUpdateTransactionsOnItemCreated = async ({ + saleInvoice, + saleInvoiceId, + tenantId, + trx, + }: ISaleInvoiceCreatedPayload) => { + runAfterTransaction(trx, async () => { + await this.invoiceStripePaymentLink.createPaymentLink( + tenantId, + saleInvoice + ); + }); + }; + + /** + * Deletes the Stripe payment link once the associated invoice deleted. + * @param {ISaleInvoiceDeletedPayload} + */ + private handleDeletePaymentLinkOnInvoiceDeleted = async ({ + saleInvoiceId, + tenantId, + }: ISaleInvoiceDeletedPayload) => { + await this.deleteStripePaymentLinkInvoice.deletePaymentLink( + tenantId, + saleInvoiceId + ); + + }; +} diff --git a/packages/server/src/system/migrations/20240909091320_create_stripe_connect_accounts_table.js b/packages/server/src/system/migrations/20240909091320_create_stripe_connect_accounts_table.js new file mode 100644 index 000000000..9aec8f70c --- /dev/null +++ b/packages/server/src/system/migrations/20240909091320_create_stripe_connect_accounts_table.js @@ -0,0 +1,20 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('stripe_accounts', (table) => { + table.increments('id').primary(); + table.string('stripe_account_id').notNullable(); + table.string('tenant_id').notNullable(); + table.timestamps(true, true); // Adds created_at and updated_at columns + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('stripe_accounts'); +}; diff --git a/packages/server/src/system/models/StripeAccount.ts b/packages/server/src/system/models/StripeAccount.ts new file mode 100644 index 000000000..2124d0c0e --- /dev/null +++ b/packages/server/src/system/models/StripeAccount.ts @@ -0,0 +1,49 @@ +import { Model } from 'objection'; + +export class StripeAccount { + /** + * Table name + */ + static get tableName() { + return 'stripe_accounts'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return {}; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Tenant = require('./Tenant'); + + return { + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: Tenant.default, + join: { + from: 'stripe_accounts.tenant_id', + to: 'tenants.id', + }, + }, + }; + } +} diff --git a/packages/server/src/system/models/index.ts b/packages/server/src/system/models/index.ts index e96fdd8ff..e753e081c 100644 --- a/packages/server/src/system/models/index.ts +++ b/packages/server/src/system/models/index.ts @@ -7,6 +7,7 @@ import PasswordReset from './PasswordReset'; import Invite from './Invite'; import SystemPlaidItem from './SystemPlaidItem'; import { Import } from './Import'; +import { StripeAccount } from './StripeAccount'; export { Plan, @@ -18,4 +19,5 @@ export { Invite, SystemPlaidItem, Import, + StripeAccount };