Stripe synchronizer extension (#15515)

Challenge 7 from "Call for projects" list
This commit is contained in:
BOHEUS
2025-11-04 11:09:26 +00:00
committed by GitHub
parent 98180c263d
commit 6065fa61c7
7 changed files with 1597 additions and 0 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
@@ -0,0 +1,28 @@
# Stripe synchronizer
Synchronizes customers from Stripe to Twenty
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
-
## Setup
1. Synchronize app
```bash
twenty auth login
cd stripe-synchronizer
twenty app sync
```
2. Go to Settings > Integrations > Stripe synchronizer > Settings and add values
## Flow
1. Retrieve webhook
2. Check if it's either subscription created or updated
3. Read customer ID, sub status and quantity
4. Read customer data
5. Check if customer company exists in Twenty, if not, create it
6. Check if related person exists in Twenty, if not, create it and link to company
## Todo
- add validation of signature key from Stripe to ensure that incoming request is valid
@@ -0,0 +1,34 @@
{
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"packageManager": "yarn@4.9.2",
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
"universalIdentifier": "0ed2bcb8-64ab-4ca1-b875-eeabf41b5f95",
"name": "stripe-synchronizer",
"description": "Plugin synchronizing data from Stripe to Twenty",
"env": {
"TWENTY_API_KEY": {
"isSecret": true,
"value": "",
"description": "Required to send requests to Twenty"
},
"TWENTY_API_URL": {
"isSecret": false,
"value": "",
"description": "Optional, defaults to cloud API URL"
},
"STRIPE_API_KEY": {
"isSecret": true,
"value": "",
"description": "Required to send request to Stripe"
}
},
"dependencies": {
"axios": "^1.13.1"
}
}
@@ -0,0 +1,14 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/serverlessFunction.schema.json",
"universalIdentifier": "cd15a738-18a5-406e-8b83-959dc52ebe14",
"name": "stripe",
"triggers": [
{
"universalIdentifier": "55f58e19-d832-43c4-9f8b-3f29fc05c162",
"type": "route",
"path": "/stripe",
"httpMethod": "POST",
"isAuthRequired": false
}
]
}
@@ -0,0 +1,328 @@
import axios from 'axios';
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';
enum stripeStatus {
Incomplete = 'INCOMPLETE',
IncompleteExpired = 'INCOMPLETE_EXPIRED',
Trialing = 'TRIALING',
Active = 'ACTIVE',
PastDue = 'PAST_DUE',
Canceled = 'CANCELED',
Unpaid = 'UNPAID',
Paused = 'PAUSED',
}
type stripeData = {
quantity: number;
}
type stripeItems = {
data: stripeData[];
};
type stripeResponse = {
customer: string,
items: stripeItems;
status: stripeStatus;
type: string;
};
type stripeCustomer = {
businessName: string;
}
type twentyObject = {
id: string;
nameSingular: string;
fields: Record<string, any>[];
}
const getCompaniesObject = async (): Promise<twentyObject> => {
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 === 'company',
);
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',
}
: {
type: 'SELECT',
objectMetadataId: objectId,
name: 'subStatus',
label: 'Sub Status',
options: [
{
color: 'iris',
label: 'Incomplete',
value: stripeStatus.Incomplete,
position: 1,
},
{
color: 'sky',
label: 'Incomplete (expired)',
value: stripeStatus.IncompleteExpired,
position: 2,
},
{
color: 'amber',
label: 'Trialing',
value: stripeStatus.Trialing,
position: 3,
},
{
color: 'green',
label: 'Active',
value: stripeStatus.Active,
position: 4,
},
{
color: 'orange',
label: 'Past due',
value: stripeStatus.PastDue,
position: 5,
},
{
color: 'brown',
label: 'Canceled',
value: stripeStatus.Canceled,
position: 6,
},
{
color: 'red',
label: 'Unpaid',
value: stripeStatus.Unpaid,
position: 7,
},
{
color: 'gray',
label: 'Paused',
value: stripeStatus.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> => {
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 ? response.data as stripeCustomer : {} as stripeCustomer ;
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const checkIfCompanyExistsInTwenty = async (name: string) => {
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.length > 0
? response.data.data.companies[0]
: {};
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
const updateTwentyCompany = async (
companyId: string,
seats: number,
subStatus: stripeStatus,
): Promise<boolean> => {
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 createTwentyCustomer = async (
customerName: string,
seats: number,
subStatus: string,
) => {
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 : {};
} catch (error) {
if (axios.isAxiosError(error)) {
throw error;
}
}
};
export const main = async (params: {
properties: unknown;
}): Promise<object> => {
if (TWENTY_API_KEY === '' || STRIPE_API_KEY === '') {
console.warn('Missing variables');
return {};
}
try { // TODO: add validation of signature key from Stripe
const { properties } = params;
const stripe = properties as stripeResponse;
const allowed_types = [
'customer.subscription.created',
'customer.subscription.updated',
];
if (!allowed_types.includes(stripe.type)) {
console.error('Wrong webhook');
return {};
}
const companyObject = await getCompaniesObject();
if (
companyObject.fields.find((field) => field.name === 'seats') === undefined
) {
const t: boolean = await createFields(companyObject.id, 'seats');
if (t == false) {
console.error('Seats field creation failed');
return {};
}
}
if (
companyObject.fields.find((field) => field.name === 'subStatus') ===
undefined
) {
const t: boolean = await createFields(companyObject.id, 'subStatus');
if (t == false) {
console.error('Sub status field creation failed');
return {};
}
}
const stripeCustomer: stripeCustomer = await getStripeCustomerData(
stripe.customer,
);
if (stripeCustomer.businessName) {
console.warn('Set customer business name in Stripe');
return {};
}
const twentyCustomer = await checkIfCompanyExistsInTwenty(
stripeCustomer.businessName,
);
if (Object.keys(twentyCustomer).length === 0) {
const a = await createTwentyCustomer(
stripeCustomer.businessName,
stripe.items.data[0].quantity,
stripe.status.toUpperCase(),
);
if (Object.keys(a).length === 0) {
console.error('Creation of Stripe customer in Twenty failed');
return {};
}
} else {
const a = await updateTwentyCompany(
twentyCustomer.id,
stripe.items.data[0].quantity,
stripe.status.toUpperCase() as stripeStatus,
);
if (!a) {
console.error('Update of Stripe customer in Twenty failed');
return {};
}
}
} catch (error) {
if (axios.isAxiosError(error)) {
console.error(error.message);
}
console.error(error);
return {};
}
};
@@ -0,0 +1,248 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10c0
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
languageName: node
linkType: hard
"async-generator-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-generator-function@npm:1.0.0"
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
languageName: node
linkType: hard
"asynckit@npm:^0.4.0":
version: 0.4.0
resolution: "asynckit@npm:0.4.0"
checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
languageName: node
linkType: hard
"axios@npm:^1.13.1":
version: 1.13.1
resolution: "axios@npm:1.13.1"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.4"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/de9c3c6de43d3ee1146d3afe78645f19450cac6a5d7235bef8b8e8eeb705c2e47e2d231dea99cecaec4dae1897c521118ca9413b9d474063c719c4d94c5b9adc
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
dependencies:
delayed-stream: "npm:~1.0.0"
checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
languageName: node
linkType: hard
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.3.0":
version: 1.3.0
resolution: "es-errors@npm:1.3.0"
checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85
languageName: node
linkType: hard
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.1.0":
version: 2.1.0
resolution: "es-set-tostringtag@npm:2.1.0"
dependencies:
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.6"
has-tostringtag: "npm:^1.0.2"
hasown: "npm:^2.0.2"
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
languageName: node
linkType: hard
"follow-redirects@npm:^1.15.6":
version: 1.15.11
resolution: "follow-redirects@npm:1.15.11"
peerDependenciesMeta:
debug:
optional: true
checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343
languageName: node
linkType: hard
"form-data@npm:^4.0.4":
version: 4.0.4
resolution: "form-data@npm:4.0.4"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.2"
mime-types: "npm:^2.1.12"
checksum: 10c0/373525a9a034b9d57073e55eab79e501a714ffac02e7a9b01be1c820780652b16e4101819785e1e18f8d98f0aee866cc654d660a435c378e16a72f2e7cac9695
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5
languageName: node
linkType: hard
"generator-function@npm:^2.0.0":
version: 2.0.1
resolution: "generator-function@npm:2.0.1"
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.6":
version: 1.3.1
resolution: "get-intrinsic@npm:1.3.1"
dependencies:
async-function: "npm:^1.0.0"
async-generator-function: "npm:^1.0.0"
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
generator-function: "npm:^2.0.0"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
languageName: node
linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.2":
version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2"
dependencies:
has-symbols: "npm:^1.0.3"
checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c
languageName: node
linkType: hard
"hasown@npm:^2.0.2":
version: 2.0.2
resolution: "hasown@npm:2.0.2"
dependencies:
function-bind: "npm:^1.1.2"
checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9
languageName: node
linkType: hard
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
languageName: node
linkType: hard
"mime-db@npm:1.52.0":
version: 1.52.0
resolution: "mime-db@npm:1.52.0"
checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
languageName: node
linkType: hard
"mime-types@npm:^2.1.12":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
mime-db: "npm:1.52.0"
checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
languageName: node
linkType: hard
"proxy-from-env@npm:^1.1.0":
version: 1.1.0
resolution: "proxy-from-env@npm:1.1.0"
checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b
languageName: node
linkType: hard
"stripe-synchronizer@workspace:.":
version: 0.0.0-use.local
resolution: "stripe-synchronizer@workspace:."
dependencies:
axios: "npm:^1.13.1"
languageName: unknown
linkType: soft