d85785e380
App to manage telemetry/billing (twenty for twenty!)
501 lines
14 KiB
TypeScript
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,
|
|
},
|
|
],
|
|
};
|