Files
twenty/packages/twenty-apps/community/stripe-synchronizer/serverlessFunctions/stripe/src/index.ts
T
Félix Malfait d85785e380 Twenty self hosting app (#15987)
App to manage telemetry/billing (twenty for twenty!)
2025-11-21 14:10:51 +01:00

501 lines
14 KiB
TypeScript

import axios from 'axios';
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
import { type stripeCustomer, type stripeEvent, type stripeStatus, type twentyObject } from './types';
const TWENTY_API_KEY: string = process.env.TWENTY_API_KEY ?? '';
const TWENTY_API_URL: string =
process.env.TWENTY_API_URL !== '' && process.env.TWENTY_API_URL !== undefined
? `${process.env.TWENTY_API_URL}/rest`
: 'https://api.twenty.com/rest';
const STRIPE_API_KEY: string = process.env.STRIPE_API_KEY ?? '';
const STRIPE_API_URL: string = 'https://api.stripe.com/v1/customers';
const getTwentyObjectData = async (
objectSingularName: string,
): Promise<twentyObject | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/metadata/objects`,
};
try {
const response = await axios.request(options);
if (response.status === 200) {
const companyObject = response.data.data.objects.find(
(object: twentyObject) => object.nameSingular === objectSingularName,
);
return (companyObject as twentyObject) ?? ({} as twentyObject);
}
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const createFields = async (objectId: string, fieldName: string) => {
const data =
fieldName === 'seats'
? {
type: 'NUMBER',
objectMetadataId: objectId,
name: 'seats',
label: 'Seats',
icon: 'IconMan',
}
: {
type: 'SELECT',
objectMetadataId: objectId,
name: 'subStatus',
label: 'Sub Status',
icon: 'IconStatusChange',
options: [
{
color: 'iris',
label: 'Incomplete',
value: 'INCOMPLETE',
position: 1,
},
{
color: 'sky',
label: 'Incomplete (expired)',
value: 'INCOMPLETE_EXPIRED',
position: 2,
},
{
color: 'amber',
label: 'Trialing',
value: 'TRIALING',
position: 3,
},
{
color: 'green',
label: 'Active',
value: 'ACTIVE',
position: 4,
},
{
color: 'orange',
label: 'Past due',
value: 'PAST_DUE',
position: 5,
},
{
color: 'brown',
label: 'Canceled',
value: 'CANCELED',
position: 6,
},
{
color: 'red',
label: 'Unpaid',
value: 'UNPAID',
position: 7,
},
{
color: 'gray',
label: 'Paused',
value: 'PAUSED',
position: 8,
},
],
};
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/metadata/fields`,
data: data,
};
try {
const response = await axios(options);
return response.status === 201;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const getStripeCustomerData = async (
customerID: string,
): Promise<stripeCustomer | undefined> => {
const options = {
method: 'GET',
url: `${STRIPE_API_URL}/${customerID}`,
auth: {
username: STRIPE_API_KEY,
password: '',
},
};
try {
const response = await axios(options);
return response.status === 200
? ({
name: response.data.name,
businessName: response.data.business_name,
email: response.data.email,
} as stripeCustomer)
: ({} as stripeCustomer);
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfCompanyExistsInTwenty = async (
name: string | undefined,
): Promise<string | undefined> => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
},
url: `${TWENTY_API_URL}/companies?filter=name%5Beq%5D%3A%22${name}%22`,
};
try {
const response = await axios(options);
return response.status === 200 &&
response.data.data.companies[0].id !== undefined
? (response.data.data.companies[0].id as string)
: '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateTwentyCompany = async (
companyId: string | undefined,
seats: number | null,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/companies/${companyId}`,
data: {
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const createTwentyCompany = async (
customerName: string | undefined,
seats: number | null,
subStatus: string,
): Promise<string | undefined> => {
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/companies`,
data: {
name: customerName,
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios(options);
return response.status === 201 ? (response.data.data.id as string) : '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfStripePersonExistsInTwenty = async (email: string | null) => {
const options = {
method: 'GET',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people?filter=emails.primaryEmail%5Beq%5D%3A%22${email}%22`, // mail is unique by default so there can be only 1 person with given mail
};
try {
const response = await axios.request(options);
return response.status === 200 &&
response.data.data.people[0].id !== undefined
? (response.data.data.people[0].id as string)
: '';
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const addTwentyPerson = async (
firstName: string,
lastName: string,
email: string,
companyId: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'POST',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people`,
data: {
firstName: firstName,
lastName: lastName,
emails: { primaryEmail: email },
companyId: companyId,
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios.request(options);
return response.status === 201;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateTwentyPerson = async (
id: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean | undefined> => {
const options = {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TWENTY_API_KEY}`,
'Content-Type': 'application/json',
},
url: `${TWENTY_API_URL}/people/${id}`,
data: {
seats: seats,
subStatus: subStatus,
},
};
try {
const response = await axios.request(options);
return response.status === 200;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
export const main = async (
params: Record<string, any>,
): Promise<object | undefined> => {
if (TWENTY_API_KEY === '' || STRIPE_API_KEY === '') {
throw new Error('Missing variables');
}
try {
// TODO: add validation of signature key from Stripe (not possible at the moment as headers aren't accessible in serverless functions)
const stripe = params as stripeEvent;
const allowed_types: string[] = [
'customer.subscription.created',
'customer.subscription.updated',
];
if (!allowed_types.includes(stripe.type)) {
throw new Error('Wrong type of webhook');
}
const stripeCustomer: stripeCustomer | undefined =
await getStripeCustomerData(stripe.data.object.customer);
if (
stripeCustomer?.businessName === undefined ||
stripeCustomer?.businessName === ''
) {
console.warn('Set customer business name in Stripe');
return {};
}
const companyObject = await getTwentyObjectData('company');
if (
companyObject?.fields.find((field) => field.name === 'seats') ===
undefined
) {
const seatsFieldCreated: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'seats')
: false;
if (!seatsFieldCreated) {
throw new Error('Seats field creation in Company object failed');
} else {
console.info('Seats field creation in Company object succeeded');
}
}
if (
companyObject?.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const subStatusFieldCreated: boolean | undefined = companyObject?.id
? await createFields(companyObject?.id, 'subStatus')
: false;
if (!subStatusFieldCreated) {
throw new Error('Sub status field creation in Company object failed');
} else {
console.info('Sub status field creation in Company object succeeded');
}
}
const personObject = await getTwentyObjectData('person');
if (
personObject?.fields.find((field) => field.name === 'seats') === undefined
) {
const seatsFieldCreated: boolean | undefined = personObject?.id
? await createFields(personObject?.id, 'seats')
: false;
if (!seatsFieldCreated) {
throw new Error('Seats field creation in People object failed');
} else {
console.info('Seats field creation in People object succeeded');
}
}
if (
personObject?.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const subStatusFieldCreated: boolean | undefined = personObject?.id
? await createFields(personObject?.id, 'subStatus')
: false;
if (!subStatusFieldCreated) {
throw new Error('Sub status field creation in People object failed');
} else {
console.info('Sub status field creation in People object succeeded');
}
}
const twentyCompanyId: string | undefined =
await checkIfCompanyExistsInTwenty(stripeCustomer?.businessName);
const seats: number =
stripe.data.object.quantity ??
stripe.data.object.items.data.reduce(
(acc, item) => acc + item.quantity,
0,
); // we don't know if subscription has only 1 item (product) or more
let updatedTwentyCompanyId: string | undefined;
if (twentyCompanyId === '') {
const twentyCompanyCreated: string | undefined =
await createTwentyCompany(
stripeCustomer?.businessName,
seats,
stripe.data.object.status.toUpperCase(),
);
if (twentyCompanyCreated === '') {
throw new Error('Creation of Stripe customer in Twenty failed');
} else {
console.log('Creation of Stripe customer in Twenty succeeded');
updatedTwentyCompanyId = twentyCompanyCreated;
}
} else {
const twentyCompanyUpdated: boolean | undefined =
await updateTwentyCompany(
twentyCompanyId,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!twentyCompanyUpdated) {
throw new Error('Update of Stripe customer in Twenty failed');
} else {
console.log('Update of Stripe customer in Twenty succeeded');
updatedTwentyCompanyId = twentyCompanyId;
}
}
if (updatedTwentyCompanyId === undefined || updatedTwentyCompanyId === '') {
throw new Error('TwentyCompanyId not found');
} else {
const stripeCustomerInTwenty: string | undefined =
await checkIfStripePersonExistsInTwenty(stripeCustomer.email);
if (stripeCustomerInTwenty === '') {
if (!stripeCustomer.name) {
throw new Error('Missing Stripe customer first or last name');
}
if (!stripeCustomer.email) {
throw new Error('Missing Stripe customer email');
}
const firstName: string = stripeCustomer.name?.split(' ')[0];
const lastName: string = stripeCustomer.name?.split(' ')[1];
const addedStripePersonToTwenty: boolean | undefined =
await addTwentyPerson(
firstName,
lastName,
stripeCustomer.email,
updatedTwentyCompanyId,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!addedStripePersonToTwenty) {
throw new Error('Adding Stripe person to Twenty failed');
} else {
console.log('Stripe person was added to Twenty');
}
} else if (stripeCustomerInTwenty !== undefined) {
const updatedStripePersonInTwenty: boolean | undefined =
await updateTwentyPerson(
stripeCustomerInTwenty,
seats,
stripe.data.object.status.toUpperCase() as stripeStatus,
);
if (!updatedStripePersonInTwenty) {
throw new Error('Update of Stripe person in Twenty failed');
} else {
console.log('Update of Stripe person in Twenty succeeded');
}
} else {
throw new Error('Twenty not found');
}
}
return {};
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.message);
return {};
}
console.error(error);
return {};
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'cd15a738-18a5-406e-8b83-959dc52ebe14',
name: 'stripe',
triggers: [
{
universalIdentifier: '55f58e19-d832-43c4-9f8b-3f29fc05c162',
type: 'route',
path: '/webhook/stripe',
httpMethod: 'POST',
isAuthRequired: false,
},
],
};