Merge pull request #3677 from payloadcms/chore/plugin-stripe
chore: imports stripe plugin
This commit is contained in:
37
packages/plugin-stripe/.eslintrc.js
Normal file
37
packages/plugin-stripe/.eslintrc.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/** @type {import('prettier').Config} */
|
||||
module.exports = {
|
||||
extends: ['@payloadcms'],
|
||||
overrides: [
|
||||
{
|
||||
extends: ['plugin:@typescript-eslint/disable-type-checked'],
|
||||
files: ['*.js', '*.cjs', '*.json', '*.md', '*.yml', '*.yaml'],
|
||||
},
|
||||
{
|
||||
files: ['package.json', 'tsconfig.json'],
|
||||
rules: {
|
||||
'perfectionist/sort-array-includes': 'off',
|
||||
'perfectionist/sort-astro-attributes': 'off',
|
||||
'perfectionist/sort-classes': 'off',
|
||||
'perfectionist/sort-enums': 'off',
|
||||
'perfectionist/sort-exports': 'off',
|
||||
'perfectionist/sort-imports': 'off',
|
||||
'perfectionist/sort-interfaces': 'off',
|
||||
'perfectionist/sort-jsx-props': 'off',
|
||||
'perfectionist/sort-keys': 'off',
|
||||
'perfectionist/sort-maps': 'off',
|
||||
'perfectionist/sort-named-exports': 'off',
|
||||
'perfectionist/sort-named-imports': 'off',
|
||||
'perfectionist/sort-object-types': 'off',
|
||||
'perfectionist/sort-objects': 'off',
|
||||
'perfectionist/sort-svelte-attributes': 'off',
|
||||
'perfectionist/sort-union-types': 'off',
|
||||
'perfectionist/sort-vue-attributes': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
root: true,
|
||||
}
|
||||
7
packages/plugin-stripe/.gitignore
vendored
Normal file
7
packages/plugin-stripe/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
demo/uploads
|
||||
build
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
15
packages/plugin-stripe/.swcrc
Normal file
15
packages/plugin-stripe/.swcrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": "inline",
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "commonjs"
|
||||
}
|
||||
}
|
||||
289
packages/plugin-stripe/README.md
Normal file
289
packages/plugin-stripe/README.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Payload Stripe Plugin
|
||||
|
||||
[](https://www.npmjs.com/package/@payloadcms/plugin-stripe)
|
||||
|
||||
A plugin for [Payload](https://github.com/payloadcms/payload) to connect [Stripe](https://stripe.com) and Payload.
|
||||
|
||||
Core features:
|
||||
|
||||
- Hides your Stripe credentials when shipping SaaS applications
|
||||
- Allows restricted keys through [Payload access control](https://payloadcms.com/docs/access-control/overview)
|
||||
- Enables a two-way communication channel between Stripe and Payload
|
||||
- Proxies the [Stripe REST API](https://stripe.com/docs/api)
|
||||
- Proxies [Stripe webhooks](https://stripe.com/docs/webhooks)
|
||||
- Automatically syncs data between the two platforms
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn add @payloadcms/plugin-stripe
|
||||
# OR
|
||||
npm i @payloadcms/plugin-stripe
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
In the `plugins` array of your [Payload config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options):
|
||||
|
||||
```js
|
||||
import { buildConfig } from 'payload/config'
|
||||
import stripePlugin from '@payloadcms/plugin-stripe'
|
||||
|
||||
const config = buildConfig({
|
||||
plugins: [
|
||||
stripePlugin({
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export default config
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
- `stripeSecretKey`: string
|
||||
|
||||
Required. Your Stripe secret key.
|
||||
|
||||
- `sync`: array
|
||||
|
||||
Optional. An array of sync configs. This will automatically configure a sync between Payload collections and Stripe resources on create, delete, and update. See [sync](#sync) for more details.
|
||||
|
||||
- `stripeWebhooksEndpointSecret`: string
|
||||
|
||||
Optional. Your Stripe webhook endpoint secret. This is needed only if you wish to sync data _from_ Stripe _to_ Payload.
|
||||
|
||||
- `rest`: boolean
|
||||
|
||||
Optional. When `true`, opens the `/api/stripe/rest` endpoint. See [endpoints](#endpoints) for more details.
|
||||
|
||||
- `webhooks`: object | function
|
||||
|
||||
Optional. Either a function to handle all webhooks events, or an object of Stripe webhook handlers, keyed to the name of the event. See [webhooks](#webhooks) for more details or for a list of all available webhooks, see [here](https://stripe.com/docs/cli/trigger#trigger-event).
|
||||
|
||||
- `logs`: boolean
|
||||
|
||||
Optional. When `true`, logs sync events to the console as they happen.
|
||||
|
||||
## Sync
|
||||
|
||||
This option will setup a basic sync between Payload collections and Stripe resources for you automatically. It will create all the necessary hooks and webhooks handlers, so the only thing you have to do is map your Payload fields to their corresponding Stripe properties. As documents are created, updated, and deleted from either Stripe or Payload, the changes are reflected on either side.
|
||||
|
||||
> NOTE: If you wish to enable a _two-way_ sync, be sure to setup [`webhooks`](#webhooks) and pass the `stripeWebhooksEndpointSecret` through your config.
|
||||
|
||||
> NOTE: Due to limitations in the Stripe API, this currently only works with top-level fields. This is because every Stripe object is a separate entity, making it difficult to abstract into a simple reusable library. In the future, we may find a pattern around this. But for now, cases like that will need to be hard-coded. See the [demo](./demo) for an example of this.
|
||||
|
||||
```js
|
||||
import { buildConfig } from 'payload/config'
|
||||
import stripePlugin from '@payloadcms/plugin-stripe'
|
||||
|
||||
const config = buildConfig({
|
||||
plugins: [
|
||||
stripePlugin({
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
stripeWebhooksEndpointSecret: process.env.STRIPE_WEBHOOKS_ENDPOINT_SECRET,
|
||||
sync: [
|
||||
{
|
||||
collection: 'customers',
|
||||
stripeResourceType: 'customers',
|
||||
stripeResourceTypeSingular: 'customer',
|
||||
fields: [
|
||||
{
|
||||
fieldPath: 'name', // this is a field on your own Payload config
|
||||
stripeProperty: 'name', // use dot notation, if applicable
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export default config
|
||||
```
|
||||
|
||||
Using `sync` will do the following:
|
||||
|
||||
- Adds and maintains a `stripeID` read-only field on each collection, this is a field generated _by Stripe_ and used as a cross-reference
|
||||
- Adds a direct link to the resource on Stripe.com
|
||||
- Adds and maintains an `skipSync` read-only flag on each collection to prevent infinite syncs when hooks trigger webhooks
|
||||
- Adds the following hooks to each collection:
|
||||
- `beforeValidate`: `createNewInStripe`
|
||||
- `beforeChange`: `syncExistingWithStripe`
|
||||
- `afterDelete`: `deleteFromStripe`
|
||||
- Handles the following Stripe webhooks
|
||||
- `STRIPE_TYPE.created`: `handleCreatedOrUpdated`
|
||||
- `STRIPE_TYPE.updated`: `handleCreatedOrUpdated`
|
||||
- `STRIPE_TYPE.deleted`: `handleDeleted`
|
||||
|
||||
### Endpoints
|
||||
|
||||
The following custom endpoints are automatically opened for you:
|
||||
|
||||
> NOTE: the `/api` part of these routes may be different based on the settings defined in your Payload config.
|
||||
|
||||
- #### `POST /api/stripe/rest`
|
||||
|
||||
If `rest` is true, proxies the [Stripe REST API](https://stripe.com/docs/api) behind [Payload access control](https://payloadcms.com/docs/access-control/overview) and returns the result. If you need to proxy the API server-side, use the [stripeProxy](#node) function.
|
||||
|
||||
```js
|
||||
const res = await fetch(`/api/stripe/rest`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Authorization: `JWT ${token}` // NOTE: do this if not in a browser (i.e. curl or Postman)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stripeMethod: 'stripe.subscriptions.list',
|
||||
stripeArgs: [
|
||||
{
|
||||
customer: 'abc',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
- #### `POST /stripe/webhooks`
|
||||
|
||||
Returns an http status code. This is where all Stripe webhook events are sent to be handled. See [webhooks](#webhooks).
|
||||
|
||||
### Webhooks
|
||||
|
||||
[Stripe webhooks](https://stripe.com/docs/webhooks) are used to sync from Stripe to Payload. Webhooks listen for events on your Stripe account so you can trigger reactions to them. Follow the steps below to enable webhooks.
|
||||
|
||||
Development:
|
||||
|
||||
1. Login using Stripe cli `stripe login`
|
||||
1. Forward events to localhost `stripe listen --forward-to localhost:3000/stripe/webhooks`
|
||||
1. Paste the given secret into your `.env` file as `STRIPE_WEBHOOKS_ENDPOINT_SECRET`
|
||||
|
||||
Production:
|
||||
|
||||
1. Login and [create a new webhook](https://dashboard.stripe.com/test/webhooks/create) from the Stripe dashboard
|
||||
1. Paste `YOUR_DOMAIN_NAME/api/stripe/webhooks` as the "Webhook Endpoint URL"
|
||||
1. Select which events to broadcast
|
||||
1. Paste the given secret into your `.env` file as `STRIPE_WEBHOOKS_ENDPOINT_SECRET`
|
||||
1. Then, handle these events using the `webhooks` portion of this plugin's config:
|
||||
|
||||
```js
|
||||
import { buildConfig } from 'payload/config'
|
||||
import stripePlugin from '@payloadcms/plugin-stripe'
|
||||
|
||||
const config = buildConfig({
|
||||
plugins: [
|
||||
stripePlugin({
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
stripeWebhooksEndpointSecret: process.env.STRIPE_WEBHOOKS_ENDPOINT_SECRET,
|
||||
webhooks: {
|
||||
'customer.subscription.updated': ({ event, stripe, stripeConfig }) => {
|
||||
// do something...
|
||||
},
|
||||
},
|
||||
// NOTE: you can also catch all Stripe webhook events and handle the event types yourself
|
||||
// webhooks: (event, stripe, stripeConfig) => {
|
||||
// switch (event.type): {
|
||||
// case 'customer.subscription.updated': {
|
||||
// // do something...
|
||||
// break;
|
||||
// }
|
||||
// default: {
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export default config
|
||||
```
|
||||
|
||||
For a full list of available webhooks, see [here](https://stripe.com/docs/cli/trigger#trigger-event).
|
||||
|
||||
### Node
|
||||
|
||||
On the server you should interface with Stripe directly using the [stripe](https://www.npmjs.com/package/stripe) npm module. That might look something like this:
|
||||
|
||||
```js
|
||||
import Stripe from 'stripe'
|
||||
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
||||
const stripe = new Stripe(stripeSecretKey, { apiVersion: '2022-08-01' })
|
||||
|
||||
export const MyFunction = async () => {
|
||||
try {
|
||||
const customer = await stripe.customers.create({
|
||||
email: data.email,
|
||||
})
|
||||
|
||||
// do something...
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can interface with the Stripe using the `stripeProxy`, which is exactly what the `/api/stripe/rest` endpoint does behind-the-scenes. Here's the same example as above, but piped through the proxy:
|
||||
|
||||
```js
|
||||
import { stripeProxy } from '@payloadcms/plugin-stripe'
|
||||
|
||||
export const MyFunction = async () => {
|
||||
try {
|
||||
const customer = await stripeProxy({
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
stripeMethod: 'customers.create',
|
||||
stripeArgs: [
|
||||
{
|
||||
email: data.email,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (customer.status === 200) {
|
||||
// do something...
|
||||
}
|
||||
|
||||
if (customer.status >= 400) {
|
||||
throw new Error(customer.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
All types can be directly imported:
|
||||
|
||||
```js
|
||||
import {
|
||||
StripeConfig,
|
||||
StripeWebhookHandler,
|
||||
StripeProxy,
|
||||
...
|
||||
} from '@payloadcms/plugin-stripe/types';
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
For development purposes, there is a full working example of how this plugin might be used in the [demo](./demo) of this repo. This demo can be developed locally using any Stripe account, you just need a working API key. Then:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:payloadcms/plugin-stripe.git \
|
||||
cd plugin-stripe && yarn \
|
||||
cd demo && yarn \
|
||||
cp .env.example .env \
|
||||
vim .env \ # add your Stripe creds to this file
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Now you have a running Payload server with this plugin installed, so you can authenticate and begin hitting the routes. To do this, open [Postman](https://www.postman.com/) and import [our config](https://github.com/payloadcms/plugin-stripe/blob/main/src/payload-stripe-plugin.postman_collection.json). First, login to retrieve your Payload access token. This token is automatically attached to the header of all other requests.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!--  -->
|
||||
6
packages/plugin-stripe/demo/.env.example
Normal file
6
packages/plugin-stripe/demo/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
PAYLOAD_PUBLIC_CMS_URL=http://localhost:3000
|
||||
MONGODB_URI=mongodb://localhost/payload-plugin-stripe
|
||||
PAYLOAD_SECRET=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOKS_ENDPOINT_SECRET=
|
||||
PAYLOAD_PUBLIC_IS_STRIPE_TEST_KEY=true
|
||||
4
packages/plugin-stripe/demo/nodemon.json
Normal file
4
packages/plugin-stripe/demo/nodemon.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"exec": "ts-node src/server.ts",
|
||||
"ext": "ts"
|
||||
}
|
||||
28
packages/plugin-stripe/demo/package.json
Normal file
28
packages/plugin-stripe/demo/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "payload-starter-typescript",
|
||||
"description": "Blank template - no collections",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/server.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
|
||||
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
|
||||
"build:server": "tsc",
|
||||
"build": "yarn build:payload && yarn build:server",
|
||||
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
|
||||
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"payload": "^1.8.2",
|
||||
"stripe": "^10.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.9",
|
||||
"cross-env": "^7.0.3",
|
||||
"nodemon": "^2.0.6",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.1.3"
|
||||
}
|
||||
}
|
||||
BIN
packages/plugin-stripe/demo/src/.DS_Store
vendored
Normal file
BIN
packages/plugin-stripe/demo/src/.DS_Store
vendored
Normal file
Binary file not shown.
112
packages/plugin-stripe/demo/src/collections/Customers.ts
Normal file
112
packages/plugin-stripe/demo/src/collections/Customers.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { CollectionConfig } from 'payload/types'
|
||||
import { LinkToDoc } from '../../../src/ui/LinkToDoc'
|
||||
|
||||
const Customers: CollectionConfig = {
|
||||
slug: 'customers',
|
||||
timestamps: true,
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'name'],
|
||||
},
|
||||
auth: {
|
||||
useAPIKey: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'subscriptions',
|
||||
label: 'Subscriptions',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description:
|
||||
'All subscriptions are managed in Stripe and will be reflected here. Use the link in the sidebar to go directly to this customer in Stripe to begin managing their subscriptions.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'link',
|
||||
label: 'Link',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: (args) =>
|
||||
LinkToDoc({
|
||||
...args,
|
||||
isTestKey: process.env.PAYLOAD_PUBLIC_IS_STRIPE_TEST_KEY === 'true',
|
||||
stripeResourceType: 'subscriptions',
|
||||
nameOfIDField: `${args.path}.stripeSubscriptionID`,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stripeSubscriptionID',
|
||||
label: 'Stripe ID',
|
||||
type: 'text',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stripeProductID',
|
||||
label: 'Product ID',
|
||||
type: 'text',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'product',
|
||||
type: 'relationship',
|
||||
relationTo: 'products',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
label: 'Active',
|
||||
value: 'active',
|
||||
},
|
||||
{
|
||||
label: 'Canceled',
|
||||
value: 'canceled',
|
||||
},
|
||||
{
|
||||
label: 'Incomplete',
|
||||
value: 'incomplete',
|
||||
},
|
||||
{
|
||||
label: 'Incomplete Expired',
|
||||
value: 'incomplete_expired',
|
||||
},
|
||||
{
|
||||
label: 'Past Due',
|
||||
value: 'past_due',
|
||||
},
|
||||
{
|
||||
label: 'Trialing',
|
||||
value: 'trialing',
|
||||
},
|
||||
{
|
||||
label: 'Unpaid',
|
||||
value: 'unpaid',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default Customers
|
||||
39
packages/plugin-stripe/demo/src/collections/Products.ts
Normal file
39
packages/plugin-stripe/demo/src/collections/Products.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CollectionConfig } from 'payload/types'
|
||||
|
||||
const Products: CollectionConfig = {
|
||||
slug: 'products',
|
||||
timestamps: true,
|
||||
admin: {
|
||||
defaultColumns: ['name'],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'price',
|
||||
label: 'Price',
|
||||
type: 'group',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'All pricing information is managed in Stripe and will be reflected here.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stripePriceID',
|
||||
label: 'Stripe Price ID',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'stripeJSON',
|
||||
label: 'Stripe JSON',
|
||||
type: 'textarea',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default Products
|
||||
20
packages/plugin-stripe/demo/src/collections/Users.ts
Normal file
20
packages/plugin-stripe/demo/src/collections/Users.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CollectionConfig } from 'payload/types'
|
||||
|
||||
const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default Users
|
||||
1
packages/plugin-stripe/demo/src/mocks/serverModule.js
Normal file
1
packages/plugin-stripe/demo/src/mocks/serverModule.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = {}
|
||||
101
packages/plugin-stripe/demo/src/payload.config.ts
Normal file
101
packages/plugin-stripe/demo/src/payload.config.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload/config'
|
||||
|
||||
import { stripePlugin } from '../../src'
|
||||
import Customers from './collections/Customers'
|
||||
import Products from './collections/Products'
|
||||
import Users from './collections/Users'
|
||||
import { subscriptionCreatedOrUpdated } from './webhooks/subscriptionCreatedOrUpdated'
|
||||
import { subscriptionDeleted } from './webhooks/subscriptionDeleted'
|
||||
import { syncPriceJSON } from './webhooks/syncPriceJSON'
|
||||
|
||||
export default buildConfig({
|
||||
serverURL: process.env.PAYLOAD_PUBLIC_CMS_URL,
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
webpack: (config) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
alias: {
|
||||
...config.resolve.alias,
|
||||
payload: path.join(__dirname, '../node_modules/payload'),
|
||||
react: path.join(__dirname, '../node_modules/react'),
|
||||
'react-dom': path.join(__dirname, '../node_modules/react-dom'),
|
||||
[path.resolve(__dirname, '../../src/index')]: path.resolve(
|
||||
__dirname,
|
||||
'../../src/admin.ts',
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return newConfig
|
||||
},
|
||||
},
|
||||
collections: [Users, Customers, Products],
|
||||
localization: {
|
||||
locales: ['en', 'es', 'de'],
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
},
|
||||
plugins: [
|
||||
stripePlugin({
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
isTestKey: process.env.PAYLOAD_PUBLIC_IS_STRIPE_TEST_KEY === 'true',
|
||||
logs: true,
|
||||
sync: [
|
||||
{
|
||||
collection: 'customers',
|
||||
stripeResourceType: 'customers',
|
||||
stripeResourceTypeSingular: 'customer',
|
||||
fields: [
|
||||
{
|
||||
fieldPath: 'name',
|
||||
stripeProperty: 'name',
|
||||
},
|
||||
{
|
||||
fieldPath: 'email',
|
||||
stripeProperty: 'email',
|
||||
},
|
||||
// NOTE: nested fields are not supported yet, because the Stripe API keeps everything separate at the top-level
|
||||
// because of this, we need to wire our own custom webhooks to handle these changes
|
||||
// In the future, support for nested fields may look something like this:
|
||||
// {
|
||||
// field: 'subscriptions.name',
|
||||
// property: 'plan.name',
|
||||
// }
|
||||
],
|
||||
},
|
||||
{
|
||||
collection: 'products',
|
||||
stripeResourceType: 'products',
|
||||
stripeResourceTypeSingular: 'product',
|
||||
fields: [
|
||||
{
|
||||
fieldPath: 'name',
|
||||
stripeProperty: 'name',
|
||||
},
|
||||
{
|
||||
fieldPath: 'price.stripePriceID',
|
||||
stripeProperty: 'default_price',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rest: false,
|
||||
webhooks: {
|
||||
'customer.subscription.created': subscriptionCreatedOrUpdated,
|
||||
'customer.subscription.updated': subscriptionCreatedOrUpdated,
|
||||
'customer.subscription.deleted': subscriptionDeleted,
|
||||
'product.created': syncPriceJSON,
|
||||
'product.updated': syncPriceJSON,
|
||||
},
|
||||
stripeWebhooksEndpointSecret: process.env.STRIPE_WEBHOOKS_ENDPOINT_SECRET,
|
||||
}),
|
||||
],
|
||||
typescript: {
|
||||
outputFile: path.resolve(__dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
27
packages/plugin-stripe/demo/src/server.ts
Normal file
27
packages/plugin-stripe/demo/src/server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import dotenv from 'dotenv'
|
||||
import express from 'express'
|
||||
import payload from 'payload'
|
||||
|
||||
dotenv.config()
|
||||
const app = express()
|
||||
|
||||
// Redirect root to Admin panel
|
||||
app.get('/', (_, res) => {
|
||||
res.redirect('/admin')
|
||||
})
|
||||
|
||||
// Initialize Payload
|
||||
const start = async (): Promise<void> => {
|
||||
await payload.init({
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
mongoURL: process.env.MONGODB_URI,
|
||||
express: app,
|
||||
onInit: () => {
|
||||
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
|
||||
},
|
||||
})
|
||||
|
||||
app.listen(3000)
|
||||
}
|
||||
|
||||
start()
|
||||
@@ -0,0 +1,107 @@
|
||||
import { APIError } from 'payload/errors'
|
||||
|
||||
export const subscriptionCreatedOrUpdated = async (args) => {
|
||||
const { event, payload, stripe, stripeConfig } = args
|
||||
|
||||
const customerStripeID = event.data.object.customer
|
||||
|
||||
payload.logger.info(
|
||||
`🪝 A new subscription was created or updated in Stripe on customer ID: ${customerStripeID}, syncing to Payload...`,
|
||||
)
|
||||
|
||||
const { id: eventID, plan, status: subscriptionStatus } = event.data.object
|
||||
|
||||
let payloadProductID
|
||||
|
||||
// First lookup the product in Payload
|
||||
try {
|
||||
payload.logger.info(`- Looking up existing Payload product with Stripe ID: ${plan.product}...`)
|
||||
|
||||
const productQuery = await payload.find({
|
||||
collection: 'products',
|
||||
depth: 0,
|
||||
where: {
|
||||
stripeID: {
|
||||
equals: plan.product,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
payloadProductID = productQuery.docs?.[0]?.id
|
||||
|
||||
if (payloadProductID) {
|
||||
payload.logger.info(
|
||||
`- Found existing product with Stripe ID: ${plan.product}. Creating relationship...`,
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
payload.logger.error(`Error finding product ${error?.message}`)
|
||||
}
|
||||
|
||||
// Now look up the customer in Payload
|
||||
try {
|
||||
payload.logger.info(
|
||||
`- Looking up existing Payload customer with Stripe ID: ${customerStripeID}.`,
|
||||
)
|
||||
|
||||
const customerReq: any = await payload.find({
|
||||
collection: 'customers',
|
||||
depth: 0,
|
||||
where: {
|
||||
stripeID: customerStripeID,
|
||||
},
|
||||
})
|
||||
|
||||
const foundCustomer = customerReq.docs[0]
|
||||
|
||||
if (foundCustomer) {
|
||||
payload.logger.info(`- Found existing customer, now updating.`)
|
||||
|
||||
const subscriptions = foundCustomer.subscriptions || []
|
||||
|
||||
const indexOfSubscription = subscriptions.findIndex(
|
||||
({ stripeSubscriptionID }) => stripeSubscriptionID === eventID,
|
||||
)
|
||||
|
||||
if (indexOfSubscription > -1) {
|
||||
payload.logger.info(`- Subscription already exists, now updating.`)
|
||||
// update existing subscription
|
||||
subscriptions[indexOfSubscription] = {
|
||||
stripeProductID: plan.product,
|
||||
product: payloadProductID,
|
||||
status: subscriptionStatus,
|
||||
}
|
||||
} else {
|
||||
payload.logger.info(`- This is a new subscription, now adding.`)
|
||||
// create new subscription
|
||||
subscriptions.push({
|
||||
stripeSubscriptionID: eventID,
|
||||
stripeProductID: plan.product,
|
||||
product: payloadProductID,
|
||||
status: subscriptionStatus,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await payload.update({
|
||||
collection: 'customers',
|
||||
id: foundCustomer.id,
|
||||
data: {
|
||||
subscriptions,
|
||||
skipSync: true,
|
||||
},
|
||||
})
|
||||
|
||||
payload.logger.info(`✅ Successfully updated subscription.`)
|
||||
} catch (error) {
|
||||
payload.logger.error(`- Error updating subscription: ${error}`)
|
||||
}
|
||||
} else {
|
||||
payload.logger.info(`- No existing customer found, cannot update subscription.`)
|
||||
}
|
||||
} catch (error) {
|
||||
new APIError(
|
||||
`Error looking up customer with Stripe ID: '${customerStripeID}': ${error?.message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { APIError } from 'payload/errors'
|
||||
|
||||
export const subscriptionDeleted = async (args) => {
|
||||
const { event, payload, stripe, stripeConfig } = args
|
||||
|
||||
const customerStripeID = event.data.object.customer
|
||||
|
||||
payload.logger.info(
|
||||
`🪝 A new subscription was deleted in Stripe on customer ID: ${customerStripeID}, deleting from Payload...`,
|
||||
)
|
||||
|
||||
const { id: eventID, plan } = event.data.object
|
||||
|
||||
// Now look up the customer in Payload
|
||||
try {
|
||||
payload.logger.info(
|
||||
`- Looking up existing Payload customer with Stripe ID: ${customerStripeID}.`,
|
||||
)
|
||||
|
||||
const customerReq: any = await payload.find({
|
||||
collection: 'customers',
|
||||
depth: 0,
|
||||
where: {
|
||||
stripeID: customerStripeID,
|
||||
},
|
||||
})
|
||||
|
||||
const foundCustomer = customerReq.docs[0]
|
||||
|
||||
if (foundCustomer) {
|
||||
payload.logger.info(`- Found existing customer, now updating.`)
|
||||
|
||||
const subscriptions = foundCustomer.subscriptions || []
|
||||
const indexOfSubscription = subscriptions.findIndex(
|
||||
({ stripeSubscriptionID }) => stripeSubscriptionID === eventID,
|
||||
)
|
||||
|
||||
if (indexOfSubscription > -1) {
|
||||
delete subscriptions[indexOfSubscription]
|
||||
}
|
||||
|
||||
try {
|
||||
await payload.update({
|
||||
collection: 'customers',
|
||||
id: foundCustomer.id,
|
||||
data: {
|
||||
subscriptions,
|
||||
skipSync: true,
|
||||
},
|
||||
})
|
||||
|
||||
payload.logger.info(`✅ Successfully deleted subscription.`)
|
||||
} catch (error) {
|
||||
payload.logger.error(`- Error deleting subscription: ${error}`)
|
||||
}
|
||||
} else {
|
||||
payload.logger.info(`- No existing customer found, cannot update subscription.`)
|
||||
}
|
||||
} catch (error) {
|
||||
new APIError(
|
||||
`Error looking up customer with Stripe ID: '${customerStripeID}': ${error?.message}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
58
packages/plugin-stripe/demo/src/webhooks/syncPriceJSON.ts
Normal file
58
packages/plugin-stripe/demo/src/webhooks/syncPriceJSON.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export const syncPriceJSON = async (args) => {
|
||||
const { event, payload, stripe } = args
|
||||
|
||||
const customerStripeID = event.data.object.customer
|
||||
|
||||
payload.logger.info(
|
||||
`🪝 A price was created or updated in Stripe on customer ID: ${customerStripeID}, syncing price JSON to Payload...`,
|
||||
)
|
||||
|
||||
const { id: eventID, default_price } = event.data.object
|
||||
|
||||
console.log(event.data.object)
|
||||
|
||||
let payloadProductID
|
||||
|
||||
// First lookup the product in Payload
|
||||
try {
|
||||
payload.logger.info(`- Looking up existing Payload product with Stripe ID: ${eventID}...`)
|
||||
|
||||
const productQuery = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
stripeID: {
|
||||
equals: eventID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
payloadProductID = productQuery.docs?.[0]?.id
|
||||
|
||||
if (payloadProductID) {
|
||||
payload.logger.info(
|
||||
`- Found existing product with Stripe ID: ${eventID}, saving price JSON...`,
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
payload.logger.error(`Error finding product ${error?.message}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const stripePrice = await stripe.prices.retrieve(default_price)
|
||||
|
||||
await payload.update({
|
||||
collection: 'products',
|
||||
id: payloadProductID,
|
||||
data: {
|
||||
price: {
|
||||
stripeJSON: JSON.stringify(stripePrice),
|
||||
},
|
||||
skipSync: true,
|
||||
},
|
||||
})
|
||||
|
||||
payload.logger.info(`✅ Successfully updated product price.`)
|
||||
} catch (error) {
|
||||
payload.logger.error(`- Error updating product price: ${error}`)
|
||||
}
|
||||
}
|
||||
15
packages/plugin-stripe/demo/tsconfig.json
Normal file
15
packages/plugin-stripe/demo/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "../",
|
||||
"jsx": "react"
|
||||
},
|
||||
"ts-node": {
|
||||
"transpileOnly": true
|
||||
}
|
||||
}
|
||||
5899
packages/plugin-stripe/demo/yarn.lock
Normal file
5899
packages/plugin-stripe/demo/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
54
packages/plugin-stripe/package.json
Normal file
54
packages/plugin-stripe/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-stripe",
|
||||
"version": "0.0.15",
|
||||
"homepage:": "https://payloadcms.com",
|
||||
"repository": "git@github.com:payloadcms/plugin-stripe.git",
|
||||
"description": "Stripe plugin for Payload",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "pnpm build:swc && pnpm build:types",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"test": "echo 'No tests available.'",
|
||||
"clean": "rimraf dist",
|
||||
"prepublishOnly": "yarn clean && yarn build"
|
||||
},
|
||||
"keywords": [
|
||||
"payload",
|
||||
"stripe",
|
||||
"cms",
|
||||
"plugin",
|
||||
"typescript",
|
||||
"payments",
|
||||
"saas",
|
||||
"subscriptions",
|
||||
"licensing"
|
||||
],
|
||||
"author": "dev@payloadcms.com",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"payload": "^1.1.8 || ^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash.get": "^4.4.2",
|
||||
"stripe": "^10.2.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash.get": "^4.4.7",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@payloadcms/eslint-config": "^0.0.1",
|
||||
"@types/express": "^4.17.9",
|
||||
"@types/react": "18.0.21",
|
||||
"payload": "^1.8.2",
|
||||
"prettier": "^2.7.1",
|
||||
"react": "^18.0.0",
|
||||
"webpack": "^5.78.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"types.js",
|
||||
"types.d.ts"
|
||||
]
|
||||
}
|
||||
44
packages/plugin-stripe/src/admin.ts
Normal file
44
packages/plugin-stripe/src/admin.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Config } from 'payload/config'
|
||||
|
||||
import type { SanitizedStripeConfig, StripeConfig } from './types'
|
||||
|
||||
import { getFields } from './fields/getFields'
|
||||
|
||||
export const stripePlugin =
|
||||
(incomingStripeConfig: StripeConfig) =>
|
||||
(config: Config): Config => {
|
||||
const { collections } = config
|
||||
|
||||
// set config defaults here
|
||||
const stripeConfig: SanitizedStripeConfig = {
|
||||
...incomingStripeConfig,
|
||||
// TODO: in the next major version, default this to `false`
|
||||
rest: incomingStripeConfig?.rest ?? true,
|
||||
sync: incomingStripeConfig?.sync || [],
|
||||
}
|
||||
|
||||
// NOTE: env variables are never passed to the client, but we need to know if `stripeSecretKey` is a test key
|
||||
// unfortunately we must set the 'isTestKey' property on the config instead of using the following code:
|
||||
// const isTestKey = stripeConfig.stripeSecretKey?.startsWith('sk_test_');
|
||||
|
||||
return {
|
||||
...config,
|
||||
collections: collections?.map((collection) => {
|
||||
const syncConfig = stripeConfig.sync?.find((sync) => sync.collection === collection.slug)
|
||||
|
||||
if (syncConfig) {
|
||||
const fields = getFields({
|
||||
collection,
|
||||
stripeConfig,
|
||||
syncConfig,
|
||||
})
|
||||
return {
|
||||
...collection,
|
||||
fields,
|
||||
}
|
||||
}
|
||||
|
||||
return collection
|
||||
}),
|
||||
}
|
||||
}
|
||||
24
packages/plugin-stripe/src/extendWebpackConfig.ts
Normal file
24
packages/plugin-stripe/src/extendWebpackConfig.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Config } from 'payload/config'
|
||||
import type { Configuration as WebpackConfig } from 'webpack'
|
||||
|
||||
import path from 'path'
|
||||
|
||||
export const extendWebpackConfig =
|
||||
(config: Config): ((webpackConfig: WebpackConfig) => WebpackConfig) =>
|
||||
(webpackConfig) => {
|
||||
const existingWebpackConfig =
|
||||
typeof config.admin?.webpack === 'function'
|
||||
? config.admin.webpack(webpackConfig)
|
||||
: webpackConfig
|
||||
|
||||
return {
|
||||
...existingWebpackConfig,
|
||||
resolve: {
|
||||
...(existingWebpackConfig.resolve || {}),
|
||||
alias: {
|
||||
...(existingWebpackConfig.resolve?.alias ? existingWebpackConfig.resolve.alias : {}),
|
||||
'@payloadcms/plugin-stripe': path.resolve(__dirname, './admin.js'),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
57
packages/plugin-stripe/src/fields/getFields.ts
Normal file
57
packages/plugin-stripe/src/fields/getFields.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { CollectionConfig, Field } from 'payload/types'
|
||||
|
||||
import type { SanitizedStripeConfig } from '../types'
|
||||
|
||||
import { LinkToDoc } from '../ui/LinkToDoc'
|
||||
|
||||
interface Args {
|
||||
collection: CollectionConfig
|
||||
stripeConfig: SanitizedStripeConfig
|
||||
syncConfig: {
|
||||
stripeResourceType: string
|
||||
}
|
||||
}
|
||||
|
||||
export const getFields = ({ collection, stripeConfig, syncConfig }: Args): Field[] => {
|
||||
const stripeIDField: Field = {
|
||||
name: 'stripeID',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
},
|
||||
label: 'Stripe ID',
|
||||
saveToJWT: true,
|
||||
type: 'text',
|
||||
}
|
||||
|
||||
const skipSyncField: Field = {
|
||||
name: 'skipSync',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
},
|
||||
label: 'Skip Sync',
|
||||
type: 'checkbox',
|
||||
}
|
||||
|
||||
const docUrlField: Field = {
|
||||
name: 'docUrl',
|
||||
admin: {
|
||||
components: {
|
||||
Field: (args) =>
|
||||
LinkToDoc({
|
||||
...args,
|
||||
isTestKey: stripeConfig.isTestKey,
|
||||
nameOfIDField: 'stripeID',
|
||||
stripeResourceType: syncConfig.stripeResourceType,
|
||||
}),
|
||||
},
|
||||
position: 'sidebar',
|
||||
},
|
||||
type: 'ui',
|
||||
}
|
||||
|
||||
const fields = [...collection.fields, stripeIDField, skipSyncField, docUrlField]
|
||||
|
||||
return fields
|
||||
}
|
||||
128
packages/plugin-stripe/src/hooks/createNewInStripe.ts
Normal file
128
packages/plugin-stripe/src/hooks/createNewInStripe.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { CollectionBeforeValidateHook, CollectionConfig } from 'payload/types'
|
||||
|
||||
import { APIError } from 'payload/errors'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
import type { StripeConfig } from '../types'
|
||||
|
||||
import { deepen } from '../utilities/deepen'
|
||||
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
||||
const stripe = new Stripe(stripeSecretKey || '', { apiVersion: '2022-08-01' })
|
||||
|
||||
export type CollectionBeforeValidateHookWithArgs = (
|
||||
args: Parameters<CollectionBeforeValidateHook>[0] & {
|
||||
collection?: CollectionConfig
|
||||
stripeConfig?: StripeConfig
|
||||
},
|
||||
) => void
|
||||
|
||||
export const createNewInStripe: CollectionBeforeValidateHookWithArgs = async (args) => {
|
||||
const { collection, data, operation, req, stripeConfig } = args
|
||||
|
||||
const { logs, sync } = stripeConfig || {}
|
||||
|
||||
const payload = req?.payload
|
||||
|
||||
const dataRef = data || {}
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
dataRef.stripeID = 'test'
|
||||
return dataRef
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
if (data?.skipSync) {
|
||||
if (logs) payload.logger.info(`Bypassing collection-level hooks.`)
|
||||
} else {
|
||||
// initialize as 'false' so that all Payload admin events sync to Stripe
|
||||
// then conditionally set to 'true' to for events that originate from webhooks
|
||||
// this will prevent webhook events from triggering an unnecessary sync / infinite loop
|
||||
dataRef.skipSync = false
|
||||
|
||||
const { slug: collectionSlug } = collection || {}
|
||||
const syncConfig = sync?.find((conf) => conf.collection === collectionSlug)
|
||||
|
||||
if (syncConfig) {
|
||||
// combine all fields of this object and match their respective values within the document
|
||||
let syncedFields = syncConfig.fields.reduce(
|
||||
(acc, field) => {
|
||||
const { fieldPath, stripeProperty } = field
|
||||
|
||||
acc[stripeProperty] = dataRef[fieldPath]
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
)
|
||||
|
||||
syncedFields = deepen(syncedFields)
|
||||
|
||||
if (operation === 'update') {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`A '${collectionSlug}' document has changed in Payload with ID: '${data?.id}', syncing with Stripe...`,
|
||||
)
|
||||
|
||||
// NOTE: the Stripe document will be created in the "afterChange" hook, so create a new stripe document here if no stripeID exists
|
||||
if (!dataRef.stripeID) {
|
||||
try {
|
||||
// NOTE: Typed as "any" because the "create" method is not standard across all Stripe resources
|
||||
const stripeResource = await stripe?.[syncConfig.stripeResourceType]?.create(
|
||||
syncedFields as any,
|
||||
)
|
||||
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`✅ Successfully created new '${syncConfig.stripeResourceType}' resource in Stripe with ID: '${stripeResource.id}'.`,
|
||||
)
|
||||
|
||||
dataRef.stripeID = stripeResource.id
|
||||
|
||||
// NOTE: this is to prevent sync in the "afterChange" hook
|
||||
dataRef.skipSync = true
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : error
|
||||
if (logs) payload.logger.error(`- Error creating Stripe document: ${msg}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operation === 'create') {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`A new '${collectionSlug}' document was created in Payload with ID: '${data?.id}', syncing with Stripe...`,
|
||||
)
|
||||
|
||||
try {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- Creating new '${syncConfig.stripeResourceType}' resource in Stripe...`,
|
||||
)
|
||||
|
||||
// NOTE: Typed as "any" because the "create" method is not standard across all Stripe resources
|
||||
const stripeResource = await stripe?.[syncConfig.stripeResourceType]?.create(
|
||||
syncedFields as any,
|
||||
)
|
||||
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`✅ Successfully created new '${syncConfig.stripeResourceType}' resource in Stripe with ID: '${stripeResource.id}'.`,
|
||||
)
|
||||
|
||||
dataRef.stripeID = stripeResource.id
|
||||
|
||||
// IMPORTANT: this is to prevent sync in the "afterChange" hook
|
||||
dataRef.skipSync = true
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : error
|
||||
throw new APIError(
|
||||
`Failed to create new '${syncConfig.stripeResourceType}' resource in Stripe: ${msg}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dataRef
|
||||
}
|
||||
58
packages/plugin-stripe/src/hooks/deleteFromStripe.ts
Normal file
58
packages/plugin-stripe/src/hooks/deleteFromStripe.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { CollectionAfterDeleteHook, CollectionConfig } from 'payload/types'
|
||||
|
||||
import { APIError } from 'payload/errors'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
import type { StripeConfig } from '../types'
|
||||
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
||||
const stripe = new Stripe(stripeSecretKey || '', { apiVersion: '2022-08-01' })
|
||||
|
||||
export type CollectionAfterDeleteHookWithArgs = (
|
||||
args: Parameters<CollectionAfterDeleteHook>[0] & {
|
||||
collection?: CollectionConfig
|
||||
stripeConfig?: StripeConfig
|
||||
},
|
||||
) => void
|
||||
|
||||
export const deleteFromStripe: CollectionAfterDeleteHookWithArgs = async (args) => {
|
||||
const { collection, doc, req, stripeConfig } = args
|
||||
|
||||
const { logs, sync } = stripeConfig || {}
|
||||
|
||||
const { payload } = req
|
||||
const { slug: collectionSlug } = collection || {}
|
||||
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`Document with ID: '${doc?.id}' in collection: '${collectionSlug}' has been deleted, deleting from Stripe...`,
|
||||
)
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (logs) payload.logger.info(`- Deleting Stripe document with ID: '${doc.stripeID}'...`)
|
||||
|
||||
const syncConfig = sync?.find((conf) => conf.collection === collectionSlug)
|
||||
|
||||
if (syncConfig) {
|
||||
try {
|
||||
const found = await stripe?.[syncConfig.stripeResourceType]?.retrieve(doc.stripeID)
|
||||
|
||||
if (found) {
|
||||
await stripe?.[syncConfig.stripeResourceType]?.del(doc.stripeID)
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`✅ Successfully deleted Stripe document with ID: '${doc.stripeID}'.`,
|
||||
)
|
||||
} else {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- Stripe document with ID: '${doc.stripeID}' not found, skipping...`,
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : error
|
||||
throw new APIError(`Failed to delete Stripe document with ID: '${doc.stripeID}': ${msg}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
packages/plugin-stripe/src/hooks/syncExistingWithStripe.ts
Normal file
82
packages/plugin-stripe/src/hooks/syncExistingWithStripe.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { CollectionBeforeChangeHook, CollectionConfig } from 'payload/types'
|
||||
|
||||
import { APIError } from 'payload/errors'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
import type { StripeConfig } from '../types'
|
||||
|
||||
import { deepen } from '../utilities/deepen'
|
||||
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
|
||||
const stripe = new Stripe(stripeSecretKey || '', { apiVersion: '2022-08-01' })
|
||||
|
||||
export type CollectionBeforeChangeHookWithArgs = (
|
||||
args: Parameters<CollectionBeforeChangeHook>[0] & {
|
||||
collection?: CollectionConfig
|
||||
stripeConfig?: StripeConfig
|
||||
},
|
||||
) => void
|
||||
|
||||
export const syncExistingWithStripe: CollectionBeforeChangeHookWithArgs = async (args) => {
|
||||
const { collection, data, operation, originalDoc, req, stripeConfig } = args
|
||||
|
||||
const { logs, sync } = stripeConfig || {}
|
||||
|
||||
const { payload } = req
|
||||
|
||||
const { slug: collectionSlug } = collection || {}
|
||||
|
||||
if (process.env.NODE_ENV !== 'test' && !data.skipSync) {
|
||||
const syncConfig = sync?.find((conf) => conf.collection === collectionSlug)
|
||||
|
||||
if (syncConfig) {
|
||||
if (operation === 'update') {
|
||||
// combine all fields of this object and match their respective values within the document
|
||||
let syncedFields = syncConfig.fields.reduce(
|
||||
(acc, field) => {
|
||||
const { fieldPath, stripeProperty } = field
|
||||
|
||||
acc[stripeProperty] = data[fieldPath]
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
)
|
||||
|
||||
syncedFields = deepen(syncedFields)
|
||||
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`A '${collectionSlug}' document has changed in Payload with ID: '${originalDoc?._id}', syncing with Stripe...`,
|
||||
)
|
||||
|
||||
if (!data.stripeID) {
|
||||
// NOTE: the "beforeValidate" hook populates this
|
||||
if (logs) payload.logger.error(`- There is no Stripe ID for this document, skipping.`)
|
||||
} else {
|
||||
if (logs)
|
||||
payload.logger.info(`- Syncing to Stripe resource with ID: '${data.stripeID}'...`)
|
||||
|
||||
try {
|
||||
const stripeResource = await stripe?.[syncConfig?.stripeResourceType]?.update(
|
||||
data.stripeID,
|
||||
syncedFields,
|
||||
)
|
||||
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`✅ Successfully synced Stripe resource with ID: '${stripeResource.id}'.`,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : error
|
||||
throw new APIError(`Failed to sync document with ID: '${data.id}' to Stripe: ${msg}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set back to 'false' so that all changes continue to sync to Stripe, see note in './createNewInStripe.ts'
|
||||
data.skipSync = false
|
||||
|
||||
return data
|
||||
}
|
||||
126
packages/plugin-stripe/src/index.ts
Normal file
126
packages/plugin-stripe/src/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { NextFunction, Response } from 'express'
|
||||
import type { Config, Endpoint } from 'payload/config'
|
||||
import type { PayloadRequest } from 'payload/types'
|
||||
|
||||
import express from 'express'
|
||||
|
||||
import type { SanitizedStripeConfig, StripeConfig } from './types'
|
||||
|
||||
import { extendWebpackConfig } from './extendWebpackConfig'
|
||||
import { getFields } from './fields/getFields'
|
||||
import { createNewInStripe } from './hooks/createNewInStripe'
|
||||
import { deleteFromStripe } from './hooks/deleteFromStripe'
|
||||
import { syncExistingWithStripe } from './hooks/syncExistingWithStripe'
|
||||
import { stripeREST } from './routes/rest'
|
||||
import { stripeWebhooks } from './routes/webhooks'
|
||||
|
||||
export const stripePlugin =
|
||||
(incomingStripeConfig: StripeConfig) =>
|
||||
(config: Config): Config => {
|
||||
const { collections } = config
|
||||
|
||||
// set config defaults here
|
||||
const stripeConfig: SanitizedStripeConfig = {
|
||||
...incomingStripeConfig,
|
||||
// TODO: in the next major version, default this to `false`
|
||||
rest: incomingStripeConfig?.rest ?? true,
|
||||
sync: incomingStripeConfig?.sync || [],
|
||||
}
|
||||
|
||||
// NOTE: env variables are never passed to the client, but we need to know if `stripeSecretKey` is a test key
|
||||
// unfortunately we must set the 'isTestKey' property on the config instead of using the following code:
|
||||
// const isTestKey = stripeConfig.stripeSecretKey?.startsWith('sk_test_');
|
||||
|
||||
return {
|
||||
...config,
|
||||
admin: {
|
||||
...config.admin,
|
||||
webpack: extendWebpackConfig(config),
|
||||
},
|
||||
collections: collections?.map((collection) => {
|
||||
const { hooks: existingHooks } = collection
|
||||
|
||||
const syncConfig = stripeConfig.sync?.find((sync) => sync.collection === collection.slug)
|
||||
|
||||
if (syncConfig) {
|
||||
const fields = getFields({
|
||||
collection,
|
||||
stripeConfig,
|
||||
syncConfig,
|
||||
})
|
||||
return {
|
||||
...collection,
|
||||
fields,
|
||||
hooks: {
|
||||
...collection.hooks,
|
||||
afterDelete: [
|
||||
...(existingHooks?.afterDelete || []),
|
||||
async (args) =>
|
||||
deleteFromStripe({
|
||||
...args,
|
||||
collection,
|
||||
stripeConfig,
|
||||
}),
|
||||
],
|
||||
beforeChange: [
|
||||
...(existingHooks?.beforeChange || []),
|
||||
async (args) =>
|
||||
syncExistingWithStripe({
|
||||
...args,
|
||||
collection,
|
||||
stripeConfig,
|
||||
}),
|
||||
],
|
||||
beforeValidate: [
|
||||
...(existingHooks?.beforeValidate || []),
|
||||
async (args) =>
|
||||
createNewInStripe({
|
||||
...args,
|
||||
collection,
|
||||
stripeConfig,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return collection
|
||||
}),
|
||||
endpoints: [
|
||||
...(config?.endpoints || []),
|
||||
{
|
||||
handler: [
|
||||
express.raw({ type: 'application/json' }),
|
||||
(req, res, next) => {
|
||||
stripeWebhooks({
|
||||
config,
|
||||
next,
|
||||
req,
|
||||
res,
|
||||
stripeConfig,
|
||||
})
|
||||
},
|
||||
],
|
||||
method: 'post',
|
||||
path: '/stripe/webhooks',
|
||||
root: true,
|
||||
},
|
||||
...(incomingStripeConfig?.rest
|
||||
? [
|
||||
{
|
||||
handler: (req: PayloadRequest, res: Response, next: NextFunction) => {
|
||||
stripeREST({
|
||||
next,
|
||||
req,
|
||||
res,
|
||||
stripeConfig,
|
||||
})
|
||||
},
|
||||
method: 'post' as Endpoint['method'],
|
||||
path: '/stripe/rest',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "Payload Stripe Plugin",
|
||||
"_exporter_id": "4309346",
|
||||
"_postman_id": "130686e1-ddd9-40f1-b031-68ec1a6413ee",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Get All Subscriptions",
|
||||
"request": {
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"stripeMethod\": \"subscriptions.list\",\n \"stripeArgs\": {\n \"customer\": \"cus_MGgt3Tuj3D66f2\"\n }\n}"
|
||||
},
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"type": "text",
|
||||
"value": "JWT {{PAYLOAD_API_TOKEN}}"
|
||||
}
|
||||
],
|
||||
"method": "POST",
|
||||
"url": {
|
||||
"host": ["localhost"],
|
||||
"path": ["api", "stripe", "rest"],
|
||||
"port": "3000",
|
||||
"raw": "localhost:3000/api/stripe/rest"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get All Products",
|
||||
"request": {
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"stripeMethod\": \"products.list\",\n \"stripeArgs\": [{\n \"limit\": 100\n }]\n}"
|
||||
},
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"type": "text",
|
||||
"value": "JWT {{PAYLOAD_API_TOKEN}}"
|
||||
}
|
||||
],
|
||||
"method": "POST",
|
||||
"url": {
|
||||
"host": ["localhost"],
|
||||
"path": ["api", "stripe", "rest"],
|
||||
"port": "3000",
|
||||
"raw": "localhost:3000/api/stripe/rest"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Get Product",
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"stripeMethod\": \"subscriptions.list\",\n \"stripeArgs\": {\n \"customer\": \"cus_MGgt3Tuj3D66f2\"\n }\n}"
|
||||
},
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"type": "text",
|
||||
"value": "JWT {{PAYLOAD_API_TOKEN}}"
|
||||
}
|
||||
],
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"host": ["localhost"],
|
||||
"path": ["api", "products", "6344664c003348299a226249"],
|
||||
"port": "3000",
|
||||
"raw": "localhost:3000/api/products/6344664c003348299a226249"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Update Product",
|
||||
"request": {
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Reactions\",\n \"price\": {\n \"stripePriceID\": \"price_1LXXU9H77M76aDnomGU5iIZu\"\n },\n \"stripeID\": \"prod_MG3bPl2yQGQK4x\",\n \"skipSync\": true\n}"
|
||||
},
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"type": "text",
|
||||
"value": "JWT {{PAYLOAD_API_TOKEN}}"
|
||||
}
|
||||
],
|
||||
"method": "PATCH",
|
||||
"url": {
|
||||
"host": ["localhost"],
|
||||
"path": ["api", "products", "6344664c003348299a226249"],
|
||||
"port": "3000",
|
||||
"raw": "localhost:3000/api/products/6344664c003348299a226249"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create Product",
|
||||
"request": {
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Test\",\n \"price\": {\n \"stripePriceID\": \"price_1LXXU9H77M76aDnomGU5iIZu\"\n },\n \"stripeID\": \"prod_MG3bPl2yQGQK4x\",\n \"skipSync\": true\n}"
|
||||
},
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"type": "text",
|
||||
"value": "JWT {{PAYLOAD_API_TOKEN}}"
|
||||
}
|
||||
],
|
||||
"method": "POST",
|
||||
"url": {
|
||||
"host": ["localhost"],
|
||||
"path": ["api", "products"],
|
||||
"port": "3000",
|
||||
"raw": "localhost:3000/api/products"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create Stripe Customer",
|
||||
"request": {
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"stripeMethod\": \"customers.create\",\n \"stripeArgs\": {\n \"email\": \"cta@hulu.com\",\n \"name\": \"Hulu\"\n }\n}"
|
||||
},
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"type": "text",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"type": "text",
|
||||
"value": "JWT {{PAYLOAD_API_TOKEN}}"
|
||||
}
|
||||
],
|
||||
"method": "POST",
|
||||
"url": {
|
||||
"host": ["localhost"],
|
||||
"path": ["api", "stripe", "rest"],
|
||||
"port": "3000",
|
||||
"raw": "localhost:3000/api/stripe/rest"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Login",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"let jsonData = pm.response.json();",
|
||||
"pm.environment.set(\"PAYLOAD_API_TOKEN\", jsonData.token);"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
},
|
||||
"raw": "{\n\t\"email\": \"jacob@trbl.design\",\n\t\"password\": \"test\"\n}"
|
||||
},
|
||||
"description": "\t",
|
||||
"header": [
|
||||
{
|
||||
"disabled": true,
|
||||
"key": "Authorization",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"method": "POST",
|
||||
"url": {
|
||||
"host": ["localhost"],
|
||||
"path": ["api", "users", "login"],
|
||||
"port": "3000",
|
||||
"raw": "localhost:3000/api/users/login"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Refresh Token",
|
||||
"protocolProfileBehavior": {
|
||||
"disabledSystemHeaders": {}
|
||||
},
|
||||
"request": {
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
},
|
||||
"raw": "{\n \"token\": \"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImphY29ic2ZsZXRjaEBnbWFpbC5jb20iLCJpZCI6IjYwODJlZjUxMzg5ZmM2MmYzNWI2MmM2ZiIsImNvbGxlY3Rpb24iOiJ1c2VycyIsImZpcnN0TmFtZSI6IkphY29iIiwibGFzdE5hbWUiOiJGbGV0Y2hlciIsIm9yZ2FuaXphdGlvbiI6IjYwN2RiNGNmYjYzMGIyNWI5YzkzNmMzNSIsImlhdCI6MTYzMTExMDk3NSwiZXhwIjoxNjMyOTI1Mzc1fQ.OL9l8jFNaCZCU-ZDQpH-EJauaRM-5JT4_Y3J_-aC-aY\"\n}"
|
||||
},
|
||||
"header": [],
|
||||
"method": "POST",
|
||||
"url": {
|
||||
"host": ["localhost"],
|
||||
"path": ["api", "users", "refresh-token"],
|
||||
"port": "3000",
|
||||
"raw": "localhost:3000/api/users/refresh-token"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Me",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"let jsonData = pm.response.json();",
|
||||
"pm.environment.set(\"PAYLOAD_API_TOKEN\", jsonData.token);"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": ""
|
||||
},
|
||||
"description": "\t",
|
||||
"header": [
|
||||
{
|
||||
"disabled": true,
|
||||
"key": "Authorization",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"host": ["localhost"],
|
||||
"path": ["api", "users", "me"],
|
||||
"port": "3000",
|
||||
"query": [
|
||||
{
|
||||
"key": "depth",
|
||||
"value": "1"
|
||||
}
|
||||
],
|
||||
"raw": "localhost:3000/api/users/me?depth=1"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
51
packages/plugin-stripe/src/routes/rest.ts
Normal file
51
packages/plugin-stripe/src/routes/rest.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Response } from 'express'
|
||||
import type { PayloadRequest } from 'payload/types'
|
||||
|
||||
import { Forbidden } from 'payload/errors'
|
||||
|
||||
import type { StripeConfig } from '../types'
|
||||
|
||||
import { stripeProxy } from '../utilities/stripeProxy'
|
||||
|
||||
export const stripeREST = async (args: {
|
||||
next: any
|
||||
req: PayloadRequest
|
||||
res: Response
|
||||
stripeConfig: StripeConfig
|
||||
}): Promise<any> => {
|
||||
const { req, res, stripeConfig } = args
|
||||
|
||||
const {
|
||||
body: {
|
||||
stripeArgs, // example: ['cus_MGgt3Tuj3D66f2'] or [{ limit: 100 }, { stripeAccount: 'acct_1J9Z4pKZ4Z4Z4Z4Z' }]
|
||||
stripeMethod, // example: 'subscriptions.list',
|
||||
},
|
||||
payload,
|
||||
user,
|
||||
} = req
|
||||
|
||||
const { stripeSecretKey } = stripeConfig
|
||||
|
||||
try {
|
||||
if (!user) {
|
||||
// TODO: make this customizable from the config
|
||||
throw new Forbidden()
|
||||
}
|
||||
|
||||
const pluginRes = await stripeProxy({
|
||||
stripeArgs,
|
||||
stripeMethod,
|
||||
stripeSecretKey,
|
||||
})
|
||||
|
||||
const { status } = pluginRes
|
||||
|
||||
res.status(status).json(pluginRes)
|
||||
} catch (error: unknown) {
|
||||
const message = `An error has occurred in the Stripe plugin REST handler: '${error}'`
|
||||
payload.logger.error(message)
|
||||
return res.status(500).json({
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
85
packages/plugin-stripe/src/routes/webhooks.ts
Normal file
85
packages/plugin-stripe/src/routes/webhooks.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Response } from 'express'
|
||||
import type { Config as PayloadConfig } from 'payload/config'
|
||||
import type { PayloadRequest } from 'payload/dist/types'
|
||||
|
||||
import Stripe from 'stripe'
|
||||
|
||||
import type { StripeConfig } from '../types'
|
||||
|
||||
import { handleWebhooks } from '../webhooks'
|
||||
|
||||
export const stripeWebhooks = async (args: {
|
||||
config: PayloadConfig
|
||||
next: any
|
||||
req: PayloadRequest
|
||||
res: Response
|
||||
stripeConfig: StripeConfig
|
||||
}): Promise<any> => {
|
||||
const { config, req, res, stripeConfig } = args
|
||||
|
||||
const { stripeSecretKey, stripeWebhooksEndpointSecret, webhooks } = stripeConfig
|
||||
|
||||
if (stripeWebhooksEndpointSecret) {
|
||||
const stripe = new Stripe(stripeSecretKey, {
|
||||
apiVersion: '2022-08-01',
|
||||
appInfo: {
|
||||
name: 'Stripe Payload Plugin',
|
||||
url: 'https://payloadcms.com',
|
||||
},
|
||||
})
|
||||
|
||||
const stripeSignature = req.headers['stripe-signature']
|
||||
|
||||
if (stripeSignature) {
|
||||
let event: Stripe.Event | undefined
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
req.body,
|
||||
stripeSignature,
|
||||
stripeWebhooksEndpointSecret,
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : err
|
||||
req.payload.logger.error(`Error constructing Stripe event: ${msg}`)
|
||||
res.status(400)
|
||||
}
|
||||
|
||||
if (event) {
|
||||
await handleWebhooks({
|
||||
config,
|
||||
event,
|
||||
payload: req.payload,
|
||||
stripe,
|
||||
stripeConfig,
|
||||
})
|
||||
|
||||
// Fire external webhook handlers if they exist
|
||||
if (typeof webhooks === 'function') {
|
||||
webhooks({
|
||||
config,
|
||||
event,
|
||||
payload: req.payload,
|
||||
stripe,
|
||||
stripeConfig,
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof webhooks === 'object') {
|
||||
const webhookEventHandler = webhooks[event.type]
|
||||
if (typeof webhookEventHandler === 'function') {
|
||||
webhookEventHandler({
|
||||
config,
|
||||
event,
|
||||
payload: req.payload,
|
||||
stripe,
|
||||
stripeConfig,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
}
|
||||
52
packages/plugin-stripe/src/types.ts
Normal file
52
packages/plugin-stripe/src/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Payload } from 'payload'
|
||||
import type { Config as PayloadConfig } from 'payload/config'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
export type StripeWebhookHandler<T = any> = (args: {
|
||||
config: PayloadConfig
|
||||
event: T
|
||||
payload: Payload
|
||||
stripe: Stripe
|
||||
stripeConfig?: StripeConfig
|
||||
}) => void
|
||||
|
||||
export interface StripeWebhookHandlers {
|
||||
[webhookName: string]: StripeWebhookHandler
|
||||
}
|
||||
|
||||
export interface FieldSyncConfig {
|
||||
fieldPath: string
|
||||
stripeProperty: string
|
||||
}
|
||||
|
||||
export interface SyncConfig {
|
||||
collection: string
|
||||
fields: FieldSyncConfig[]
|
||||
stripeResourceType: 'customers' | 'products' // TODO: get this from Stripe types
|
||||
stripeResourceTypeSingular: 'customer' | 'product' // TODO: there must be a better way to do this
|
||||
}
|
||||
|
||||
export interface StripeConfig {
|
||||
isTestKey?: boolean
|
||||
logs?: boolean
|
||||
// @deprecated this will default as `false` in the next major version release
|
||||
rest?: boolean
|
||||
stripeSecretKey: string
|
||||
stripeWebhooksEndpointSecret?: string
|
||||
sync?: SyncConfig[]
|
||||
webhooks?: StripeWebhookHandler | StripeWebhookHandlers
|
||||
}
|
||||
|
||||
export type SanitizedStripeConfig = StripeConfig & {
|
||||
sync: SyncConfig[] // convert to required
|
||||
}
|
||||
|
||||
export type StripeProxy = (args: {
|
||||
stripeArgs: any[]
|
||||
stripeMethod: string
|
||||
stripeSecretKey: string
|
||||
}) => Promise<{
|
||||
data?: any
|
||||
message?: string
|
||||
status: number
|
||||
}>
|
||||
52
packages/plugin-stripe/src/ui/LinkToDoc.tsx
Normal file
52
packages/plugin-stripe/src/ui/LinkToDoc.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { UIField } from 'payload/dist/fields/config/types'
|
||||
|
||||
import { useFormFields } from 'payload/components/forms'
|
||||
import CopyToClipboard from 'payload/dist/admin/components/elements/CopyToClipboard'
|
||||
import React from 'react'
|
||||
|
||||
export const LinkToDoc: React.FC<
|
||||
UIField & {
|
||||
isTestKey: boolean
|
||||
nameOfIDField: string
|
||||
stripeResourceType: string
|
||||
}
|
||||
> = (props) => {
|
||||
const { isTestKey, nameOfIDField, stripeResourceType } = props
|
||||
|
||||
const field = useFormFields(([fields]) => fields[nameOfIDField])
|
||||
const { value: stripeID } = field || {}
|
||||
|
||||
const stripeEnv = isTestKey ? 'test/' : ''
|
||||
const href = `https://dashboard.stripe.com/${stripeEnv}${stripeResourceType}/${stripeID}`
|
||||
|
||||
if (stripeID) {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<span
|
||||
className="label"
|
||||
style={{
|
||||
color: '#9A9A9A',
|
||||
}}
|
||||
>
|
||||
View in Stripe
|
||||
</span>
|
||||
<CopyToClipboard value={href} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<a href={href} rel="noreferrer noopener" target="_blank">
|
||||
{href}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
21
packages/plugin-stripe/src/utilities/deepen.ts
Normal file
21
packages/plugin-stripe/src/utilities/deepen.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// converts an object of dot notation keys to a nested object
|
||||
// i.e. { 'price.stripePriceID': '123' } to { price: { stripePriceID: '123' } }
|
||||
|
||||
export const deepen = (obj: Record<string, any>): Record<string, any> => {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const key in obj) {
|
||||
const value = obj[key]
|
||||
const keys = key.split('.')
|
||||
let current = result
|
||||
keys.forEach((k, index) => {
|
||||
if (index === keys.length - 1) {
|
||||
current[k] = value
|
||||
} else {
|
||||
current[k] = current[k] || {}
|
||||
current = current[k]
|
||||
}
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
47
packages/plugin-stripe/src/utilities/stripeProxy.ts
Normal file
47
packages/plugin-stripe/src/utilities/stripeProxy.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import lodashGet from 'lodash.get'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
import type { StripeProxy } from '../types'
|
||||
|
||||
export const stripeProxy: StripeProxy = async ({ stripeArgs, stripeMethod, stripeSecretKey }) => {
|
||||
const stripe = new Stripe(stripeSecretKey, {
|
||||
apiVersion: '2022-08-01',
|
||||
appInfo: {
|
||||
name: 'Stripe Payload Plugin',
|
||||
url: 'https://payloadcms.com',
|
||||
},
|
||||
})
|
||||
|
||||
if (typeof stripeMethod === 'string') {
|
||||
const topLevelMethod = stripeMethod.split('.')[0] as keyof Stripe
|
||||
const contextToBind = stripe[topLevelMethod]
|
||||
// NOTE: 'lodashGet' uses dot notation to get the property of an object
|
||||
// NOTE: Stripe API methods using reference "this" within their functions, so we need to bind context
|
||||
const foundMethod = lodashGet(stripe, stripeMethod).bind(contextToBind)
|
||||
|
||||
if (typeof foundMethod === 'function') {
|
||||
if (Array.isArray(stripeArgs)) {
|
||||
try {
|
||||
const stripeResponse = await foundMethod(...stripeArgs)
|
||||
return {
|
||||
data: stripeResponse,
|
||||
status: 200,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
message: `A Stripe API error has occurred: ${error}`,
|
||||
status: 404,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Argument 'stripeArgs' must be an array.`)
|
||||
}
|
||||
} else {
|
||||
throw Error(
|
||||
`The provided Stripe method of '${stripeMethod}' is not a part of the Stripe API.`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw Error('You must provide a Stripe method to call.')
|
||||
}
|
||||
}
|
||||
196
packages/plugin-stripe/src/webhooks/handleCreatedOrUpdated.ts
Normal file
196
packages/plugin-stripe/src/webhooks/handleCreatedOrUpdated.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import type { SanitizedStripeConfig, StripeWebhookHandler } from '../types'
|
||||
|
||||
import { deepen } from '../utilities/deepen'
|
||||
|
||||
type HandleCreatedOrUpdated = (
|
||||
args: Parameters<StripeWebhookHandler>[0] & {
|
||||
resourceType: string
|
||||
syncConfig: SanitizedStripeConfig['sync'][0]
|
||||
},
|
||||
) => void
|
||||
|
||||
export const handleCreatedOrUpdated: HandleCreatedOrUpdated = async (args) => {
|
||||
const { config: payloadConfig, event, payload, resourceType, stripeConfig, syncConfig } = args
|
||||
|
||||
const { logs } = stripeConfig || {}
|
||||
|
||||
const stripeDoc: any = event?.data?.object || {}
|
||||
|
||||
const { id: stripeID, object: eventObject } = stripeDoc
|
||||
|
||||
// NOTE: the Stripe API does not nest fields, everything is an object at the top level
|
||||
// if the event object and resource type don't match, this change was not top-level
|
||||
const isNestedChange = eventObject !== resourceType
|
||||
|
||||
// let stripeID = docID;
|
||||
// if (isNestedChange) {
|
||||
// const parentResource = stripeDoc[resourceType];
|
||||
// stripeID = parentResource;
|
||||
// }
|
||||
|
||||
if (isNestedChange) {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- This change occurred on a nested field of ${resourceType}. Nested fields are not yet supported in auto-sync but can be manually setup.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!isNestedChange) {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- A new document was created or updated in Stripe, now syncing to Payload...`,
|
||||
)
|
||||
|
||||
const collectionSlug = syncConfig?.collection
|
||||
|
||||
const isAuthCollection = Boolean(
|
||||
payloadConfig?.collections?.find((collection) => collection.slug === collectionSlug)?.auth,
|
||||
)
|
||||
|
||||
// First, search for an existing document in Payload
|
||||
const payloadQuery = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where: {
|
||||
stripeID: {
|
||||
equals: stripeID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const foundDoc = payloadQuery.docs[0] as any
|
||||
|
||||
// combine all properties of the Stripe doc and match their respective fields within the document
|
||||
let syncedData = syncConfig.fields.reduce(
|
||||
(acc, field) => {
|
||||
const { fieldPath, stripeProperty } = field
|
||||
|
||||
acc[fieldPath] = stripeDoc[stripeProperty]
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
)
|
||||
|
||||
syncedData = deepen({
|
||||
...syncedData,
|
||||
skipSync: true,
|
||||
stripeID,
|
||||
})
|
||||
|
||||
if (!foundDoc) {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- No existing '${collectionSlug}' document found with Stripe ID: '${stripeID}', creating new...`,
|
||||
)
|
||||
|
||||
// auth docs must use unique emails
|
||||
let authDoc = null
|
||||
|
||||
if (isAuthCollection) {
|
||||
try {
|
||||
if (stripeDoc?.email) {
|
||||
const authQuery = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where: {
|
||||
email: {
|
||||
equals: stripeDoc.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
authDoc = authQuery.docs[0] as any
|
||||
|
||||
if (authDoc) {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- Account already exists with e-mail: ${stripeDoc.email}, updating now...`,
|
||||
)
|
||||
|
||||
// account exists by email, so update it instead
|
||||
try {
|
||||
await payload.update({
|
||||
id: authDoc.id,
|
||||
collection: collectionSlug,
|
||||
data: syncedData,
|
||||
})
|
||||
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`✅ Successfully updated '${collectionSlug}' document in Payload with ID: '${authDoc.id}.'`,
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : err
|
||||
if (logs)
|
||||
payload.logger.error(
|
||||
`- Error updating existing '${collectionSlug}' document: ${msg}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (logs)
|
||||
payload.logger.error(
|
||||
`No email was provided from Stripe, cannot create new '${collectionSlug}' document.`,
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : error
|
||||
if (logs)
|
||||
payload.logger.error(`Error looking up '${collectionSlug}' document in Payload: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthCollection || (isAuthCollection && !authDoc)) {
|
||||
try {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- Creating new '${collectionSlug}' document in Payload with Stripe ID: '${stripeID}'.`,
|
||||
)
|
||||
|
||||
// generate a strong, unique password for the new user
|
||||
const password: string = uuid()
|
||||
|
||||
await payload.create({
|
||||
collection: collectionSlug,
|
||||
data: {
|
||||
...syncedData,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
},
|
||||
disableVerificationEmail: isAuthCollection ? true : undefined,
|
||||
})
|
||||
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`✅ Successfully created new '${collectionSlug}' document in Payload with Stripe ID: '${stripeID}'.`,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : error
|
||||
if (logs) payload.logger.error(`Error creating new document in Payload: ${msg}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- Existing '${collectionSlug}' document found in Payload with Stripe ID: '${stripeID}', updating now...`,
|
||||
)
|
||||
|
||||
try {
|
||||
await payload.update({
|
||||
id: foundDoc.id,
|
||||
collection: collectionSlug,
|
||||
data: syncedData,
|
||||
})
|
||||
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`✅ Successfully updated '${collectionSlug}' document in Payload from Stripe ID: '${stripeID}'.`,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : error
|
||||
if (logs)
|
||||
payload.logger.error(`Error updating '${collectionSlug}' document in Payload: ${msg}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
82
packages/plugin-stripe/src/webhooks/handleDeleted.ts
Normal file
82
packages/plugin-stripe/src/webhooks/handleDeleted.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { SanitizedStripeConfig, StripeWebhookHandler } from '../types'
|
||||
|
||||
type HandleDeleted = (
|
||||
args: Parameters<StripeWebhookHandler>[0] & {
|
||||
resourceType: string
|
||||
syncConfig: SanitizedStripeConfig['sync'][0]
|
||||
},
|
||||
) => void
|
||||
|
||||
export const handleDeleted: HandleDeleted = async (args) => {
|
||||
const { event, payload, resourceType, stripeConfig, syncConfig } = args
|
||||
|
||||
const { logs } = stripeConfig || {}
|
||||
|
||||
const collectionSlug = syncConfig?.collection
|
||||
|
||||
const {
|
||||
id: stripeID,
|
||||
object: eventObject, // use this to determine if this is a nested field
|
||||
}: any = event?.data?.object || {}
|
||||
|
||||
// if the event object and resource type don't match, this deletion was not top-level
|
||||
const isNestedDelete = eventObject !== resourceType
|
||||
|
||||
if (isNestedDelete) {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- This deletion occurred on a nested field of ${resourceType}. Nested fields are not yet supported.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!isNestedDelete) {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- A '${resourceType}' resource was deleted in Stripe, now deleting '${collectionSlug}' document in Payload with Stripe ID: '${stripeID}'...`,
|
||||
)
|
||||
|
||||
try {
|
||||
const payloadQuery = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where: {
|
||||
stripeID: {
|
||||
equals: stripeID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const foundDoc = payloadQuery.docs[0] as any
|
||||
|
||||
if (!foundDoc) {
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- Nothing to delete, no existing document found with Stripe ID: '${stripeID}'.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (foundDoc) {
|
||||
if (logs) payload.logger.info(`- Deleting Payload document with ID: '${foundDoc.id}'...`)
|
||||
|
||||
try {
|
||||
payload.delete({
|
||||
id: foundDoc.id,
|
||||
collection: collectionSlug,
|
||||
})
|
||||
|
||||
// NOTE: the `afterDelete` hook will trigger, which will attempt to delete the document from Stripe and safely error out
|
||||
// There is no known way of preventing this from happening. In other hooks we use the `skipSync` field, but here the document is already deleted.
|
||||
if (logs)
|
||||
payload.logger.info(
|
||||
`- ✅ Successfully deleted Payload document with ID: '${foundDoc.id}'.`,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : error
|
||||
if (logs) payload.logger.error(`Error deleting document: ${msg}`)
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : error
|
||||
if (logs) payload.logger.error(`Error deleting document: ${msg}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
55
packages/plugin-stripe/src/webhooks/index.ts
Normal file
55
packages/plugin-stripe/src/webhooks/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { StripeWebhookHandler } from '../types'
|
||||
|
||||
import { handleCreatedOrUpdated } from './handleCreatedOrUpdated'
|
||||
import { handleDeleted } from './handleDeleted'
|
||||
|
||||
export const handleWebhooks: StripeWebhookHandler = async (args) => {
|
||||
const { event, payload, stripeConfig } = args
|
||||
|
||||
if (stripeConfig?.logs)
|
||||
payload.logger.info(`🪝 Received Stripe '${event.type}' webhook event with ID: '${event.id}'.`)
|
||||
|
||||
// could also traverse into event.data.object.object to get the type, but that seems unreliable
|
||||
// use cli: `stripe resources` to see all available resources
|
||||
const resourceType = event.type.split('.')[0]
|
||||
const method = event.type.split('.').pop()
|
||||
|
||||
const syncConfig = stripeConfig?.sync?.find(
|
||||
(sync) => sync.stripeResourceTypeSingular === resourceType,
|
||||
)
|
||||
|
||||
if (syncConfig) {
|
||||
switch (method) {
|
||||
case 'created': {
|
||||
await handleCreatedOrUpdated({
|
||||
...args,
|
||||
resourceType,
|
||||
stripeConfig,
|
||||
syncConfig,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'updated': {
|
||||
await handleCreatedOrUpdated({
|
||||
...args,
|
||||
resourceType,
|
||||
stripeConfig,
|
||||
syncConfig,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'deleted': {
|
||||
await handleDeleted({
|
||||
...args,
|
||||
resourceType,
|
||||
stripeConfig,
|
||||
syncConfig,
|
||||
})
|
||||
break
|
||||
}
|
||||
default: {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
packages/plugin-stripe/tsconfig.json
Normal file
24
packages/plugin-stripe/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true, // Make sure typescript knows that this module depends on their references
|
||||
"noEmit": false /* Do not emit outputs. */,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
|
||||
"rootDir": "./src" /* Specify the root folder within your source files. */
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"build",
|
||||
"tests",
|
||||
"test",
|
||||
"node_modules",
|
||||
".eslintrc.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.spec.tsx"
|
||||
],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
|
||||
"references": [{ "path": "../payload" }] // db-mongodb depends on payload
|
||||
}
|
||||
1
packages/plugin-stripe/types.d.ts
vendored
Normal file
1
packages/plugin-stripe/types.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dist/types'
|
||||
1
packages/plugin-stripe/types.js
Normal file
1
packages/plugin-stripe/types.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/types')
|
||||
Reference in New Issue
Block a user