feat: wip Stripe connect integration

This commit is contained in:
Ahmed Bouhuolia
2024-09-09 14:18:04 +02:00
parent a183666df6
commit 162b92ce84
15 changed files with 410 additions and 4 deletions

View File

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

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

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

View File

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

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

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

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

View File

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

View File

@@ -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<string>}
*/
public async createAccountSession(accountId: string): Promise<string> {
try {
const accountSession = await this.stripe.accountSessions.create({
account: accountId,
@@ -28,7 +33,7 @@ export class StripePaymentService {
}
}
public async createAccount() {
public async createAccount(): Promise<string> {
try {
const account = await this.stripe.accounts.create({});

View File

@@ -0,0 +1 @@
export const STRIPE_PAYMENT_LINK_REDIRECT = 'https://your_redirect_url.com';

View File

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

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