@splendidlabz/third-party 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.cjs +3 -0
- package/buttondown.js +101 -0
- package/google.js +97 -0
- package/package.json +23 -0
- package/postmark.js +47 -0
- package/stripe.js +88 -0
package/.eslintrc.cjs
ADDED
package/buttondown.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { omitEmpty } from '@splendidlabz/utils'
|
|
2
|
+
import { createZlFetch, reject } from 'zl-fetch'
|
|
3
|
+
|
|
4
|
+
const token = import.meta.env.BUTTONDOWN_KEY
|
|
5
|
+
|
|
6
|
+
const BASE_URL = 'https://api.buttondown.email/v1'
|
|
7
|
+
const buttonDown = createZlFetch(BASE_URL, {
|
|
8
|
+
headers: {
|
|
9
|
+
Accept: 'application/json',
|
|
10
|
+
Authorization: `Token ${token}`,
|
|
11
|
+
},
|
|
12
|
+
returnError: true,
|
|
13
|
+
debug: true,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export async function getSubscriber(email) {
|
|
17
|
+
const { response } = await buttonDown.get(`/subscribers/${email}`)
|
|
18
|
+
if (response) return response.body
|
|
19
|
+
else return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_SUBSCRIBER_OPTIONS = {
|
|
23
|
+
notes: '',
|
|
24
|
+
metadata: {},
|
|
25
|
+
tags: [],
|
|
26
|
+
referrer_url: '',
|
|
27
|
+
utm_campagin: '',
|
|
28
|
+
utm_medium: '',
|
|
29
|
+
utm_source: '',
|
|
30
|
+
referring_subscriber_id: '',
|
|
31
|
+
subscriber_type: 'regular',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Creates a subscriber
|
|
35
|
+
// Notes:
|
|
36
|
+
// - Button down throws an error if the email is invalid. Though this only occurs after using fake emails for many times. Shouldn't be a problem to ignore.
|
|
37
|
+
export async function createSubscriber(email, props = {}) {
|
|
38
|
+
const data = omitEmpty({ ...DEFAULT_SUBSCRIBER_OPTIONS, ...props })
|
|
39
|
+
if (props.double_opt_in === true) data.subscriber_type = 'unactivated'
|
|
40
|
+
const { response, error } = await buttonDown.post('/subscribers', {
|
|
41
|
+
body: { email, ...data },
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (response) return response.body
|
|
45
|
+
if (error) {
|
|
46
|
+
if (error.code === 'email_invalid') return // Swallow this error
|
|
47
|
+
return reject(error)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// https://docs.buttondown.email/api-subscribers-update
|
|
52
|
+
// Updates a subscriber.
|
|
53
|
+
// Notes:
|
|
54
|
+
// - Buttondown throws an error if subscriber doesn't exist.
|
|
55
|
+
// - It's not possible to change a user's email with this API.
|
|
56
|
+
export async function updateSubscriber(email, props = {}, options = {}) {
|
|
57
|
+
const {
|
|
58
|
+
metadata: incomingMetadata = {},
|
|
59
|
+
tags: incomingTags = [],
|
|
60
|
+
...rest
|
|
61
|
+
} = props
|
|
62
|
+
const { mergeTags = true, mergeMetadata = true } = options
|
|
63
|
+
|
|
64
|
+
const sub = await getSubscriber(email)
|
|
65
|
+
if (!sub) return reject({ status: 400, message: 'Subscriber not found' })
|
|
66
|
+
|
|
67
|
+
// Determine whether to merge or overwrite tags and metadata
|
|
68
|
+
const { tags, metadata } = sub
|
|
69
|
+
const newTags = mergeTags
|
|
70
|
+
? Array.from(new Set(tags, incomingTags))
|
|
71
|
+
: incomingTags
|
|
72
|
+
const newMetadata = mergeMetadata
|
|
73
|
+
? Object.assign(metadata, incomingMetadata)
|
|
74
|
+
: incomingMetadata
|
|
75
|
+
|
|
76
|
+
const data = omitEmpty({
|
|
77
|
+
...rest,
|
|
78
|
+
tags: newTags,
|
|
79
|
+
metadata: newMetadata,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const { response, error } = await buttonDown.patch(`/subscribers/${email}`, {
|
|
83
|
+
body: data,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (response) return response.body
|
|
87
|
+
if (error) return reject(error)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function upsertSubscriber(email, props = {}) {
|
|
91
|
+
const sub = await getSubscriber(email)
|
|
92
|
+
if (sub) return updateSubscriber(email, props)
|
|
93
|
+
else return createSubscriber(email, props)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function removeTagsFromSubscriber(email, tags = []) {
|
|
97
|
+
const sub = await getSubscriber(email)
|
|
98
|
+
if (!sub) return reject({ status: 400, message: 'Subscriber not found' })
|
|
99
|
+
const newTags = sub.tags.filter(tag => !tags.includes(tag))
|
|
100
|
+
return updateSubscriber(email, { tags: newTags }, { mergeTags: false })
|
|
101
|
+
}
|
package/google.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { google } from 'googleapis'
|
|
2
|
+
import { reject } from 'zl-fetch'
|
|
3
|
+
|
|
4
|
+
// 1. Create New Google Project (in the cloud console https://console.cloud.google.com/apis/library?project=splendid-labz)
|
|
5
|
+
// 2. Enable API Library (https://console.cloud.google.com/apis/library?project=splendid-labz)
|
|
6
|
+
// 3. Create credentials
|
|
7
|
+
// Create a service account. Choose application data.
|
|
8
|
+
// 4. Account Owner (cos simpler)
|
|
9
|
+
// Go to Credentials, under Service Account, open it up. Create a key. This key will be saved in on your computer.
|
|
10
|
+
// 5. Open up the key. Add the service account to the google sheet(s), or better, to the entire drive folder. This ensures you don't have to manually add credentials.
|
|
11
|
+
//
|
|
12
|
+
// https://sheets.googleapis.com
|
|
13
|
+
// POST /v4/spreadsheets/{spreadsheetId}/values/{range}:append
|
|
14
|
+
import { promisify } from 'util'
|
|
15
|
+
|
|
16
|
+
export function GoogleSheets(credentials, options) {
|
|
17
|
+
const auth = new google.auth.GoogleAuth({
|
|
18
|
+
keyFile: credentials,
|
|
19
|
+
scopes: [
|
|
20
|
+
'https://www.googleapis.com/auth/spreadsheets',
|
|
21
|
+
'https://www.googleapis.com/auth/drive',
|
|
22
|
+
],
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const service = google.sheets({ version: 'v4' })
|
|
26
|
+
const sheets = service.spreadsheets.values
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
async read(spreadsheetId, { range = 'Sheet1!A1' } = {}) {
|
|
30
|
+
const get = promisify(sheets.get).bind(sheets)
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const res = await get({
|
|
34
|
+
auth,
|
|
35
|
+
spreadsheetId,
|
|
36
|
+
range: 'Sheet1',
|
|
37
|
+
})
|
|
38
|
+
const rows = res.data.values
|
|
39
|
+
return rows
|
|
40
|
+
} catch (err) {
|
|
41
|
+
const { status, errors } = err
|
|
42
|
+
return reject({ status, message: errors[0].message })
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Check google-api samples for the actual format.
|
|
47
|
+
// It's different from the REST values mentioned in Google dcos.
|
|
48
|
+
async append(
|
|
49
|
+
spreadsheetId,
|
|
50
|
+
{ majorDimension, range = 'Sheet1', values = [] } = {},
|
|
51
|
+
) {
|
|
52
|
+
const append = promisify(sheets.append).bind(sheets)
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const res = await append({
|
|
56
|
+
auth,
|
|
57
|
+
spreadsheetId,
|
|
58
|
+
range,
|
|
59
|
+
majorDimension: majorDimension.toUpperCase(),
|
|
60
|
+
valueInputOption: 'USER_ENTERED',
|
|
61
|
+
requestBody: {
|
|
62
|
+
major_dimension: 'ROWS',
|
|
63
|
+
values,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
return res.data.updates
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const { status, errors } = err
|
|
69
|
+
return reject({ status, message: errors[0].message })
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async appendOne(
|
|
74
|
+
spreadsheetId,
|
|
75
|
+
{ majorDimension = 'ROWS', range = 'Sheet1', value = [] } = {},
|
|
76
|
+
) {
|
|
77
|
+
const append = promisify(sheets.append).bind(sheets)
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const res = await append({
|
|
81
|
+
auth,
|
|
82
|
+
spreadsheetId,
|
|
83
|
+
range,
|
|
84
|
+
valueInputOption: 'USER_ENTERED',
|
|
85
|
+
requestBody: {
|
|
86
|
+
major_dimension: 'ROWS',
|
|
87
|
+
values: [value],
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
return res.data.updates
|
|
91
|
+
} catch (err) {
|
|
92
|
+
const { status, errors } = err
|
|
93
|
+
return reject({ status, message: errors[0].message })
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@splendidlabz/third-party",
|
|
3
|
+
"prettier": "@splendidlabz/prettier-config",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"scripts": {
|
|
10
|
+
"lint": "eslint . --fix"
|
|
11
|
+
},
|
|
12
|
+
"author": "Zell Liew <zellwk@gmail.com>",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"googleapis": "^140.0.1",
|
|
15
|
+
"http-errors": "^2.0.0",
|
|
16
|
+
"postmark": "^4.0.4",
|
|
17
|
+
"zl-fetch": "^6.0.6"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@splendidlabz/eslint-config": "*",
|
|
21
|
+
"@splendidlabz/prettier-config": "*"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/postmark.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import postmark from 'postmark'
|
|
2
|
+
|
|
3
|
+
const POSTMARK_KEY = import.meta.env.POSTMARK_KEY
|
|
4
|
+
const DEFAULT_OPTIONS = {
|
|
5
|
+
MessageStream: 'outbound', // outbound | broadcast
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const client = new postmark.ServerClient(POSTMARK_KEY)
|
|
9
|
+
|
|
10
|
+
// Allow for default options
|
|
11
|
+
export default function Postmark(options) {
|
|
12
|
+
const baseOpts = Object.assign({}, DEFAULT_OPTIONS, options)
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
// Sends email with Postmark
|
|
16
|
+
sendEmail(newOptions) {
|
|
17
|
+
const opts = Object.assign({}, baseOpts, newOptions)
|
|
18
|
+
const { from, to, replyTo, subject, html, text, ...rest } = opts
|
|
19
|
+
|
|
20
|
+
return client.sendEmail({
|
|
21
|
+
From: from,
|
|
22
|
+
To: to,
|
|
23
|
+
ReplyTo: replyTo,
|
|
24
|
+
Subject: subject,
|
|
25
|
+
HTMLBody: html,
|
|
26
|
+
TextBody: text,
|
|
27
|
+
...rest,
|
|
28
|
+
})
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Allow for direct usage
|
|
34
|
+
export async function sendEmail(newOptions) {
|
|
35
|
+
const opts = Object.assign({}, DEFAULT_OPTIONS, newOptions)
|
|
36
|
+
const { from, to, replyTo, subject, html, text, ...rest } = opts
|
|
37
|
+
|
|
38
|
+
return client.sendEmail({
|
|
39
|
+
From: from,
|
|
40
|
+
To: to,
|
|
41
|
+
ReplyTo: replyTo,
|
|
42
|
+
Subject: subject,
|
|
43
|
+
HTMLBody: html,
|
|
44
|
+
TextBody: text,
|
|
45
|
+
...rest,
|
|
46
|
+
})
|
|
47
|
+
}
|
package/stripe.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { omitEmpty, reject } from '@splendidlabz/utils'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verifies a Stripe webhook event.
|
|
5
|
+
* @param {Object} options
|
|
6
|
+
* @param {Request} options.request - The incoming request object.
|
|
7
|
+
* @param {Object} options.stripe - The Stripe instance.
|
|
8
|
+
* @param {string} options.webhookSecret - The webhook secret
|
|
9
|
+
* @param {boolean} [options.checkOnDev=false] - Whether to perform verification in development mode.
|
|
10
|
+
* @returns {Promise<Object|Response>} The event object
|
|
11
|
+
* @throws {Error} If there is a verification error.
|
|
12
|
+
*/
|
|
13
|
+
export async function verifyStripeWebhookEvent({
|
|
14
|
+
request,
|
|
15
|
+
stripe,
|
|
16
|
+
webhookSecret,
|
|
17
|
+
verify = true,
|
|
18
|
+
}) {
|
|
19
|
+
// Allow skipping verification
|
|
20
|
+
if (verify === false) return request.json()
|
|
21
|
+
|
|
22
|
+
// Production.
|
|
23
|
+
// Must ensure Stripe is OK
|
|
24
|
+
const sig = request.headers.get('Stripe-Signature')
|
|
25
|
+
const payload = await request.text()
|
|
26
|
+
try {
|
|
27
|
+
return stripe.webhooks.constructEvent(payload, sig, webhookSecret)
|
|
28
|
+
} catch (err) {
|
|
29
|
+
return reject({ status: 500, message: `Webhook Error: ${err.message}` })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {('subscription_update'|'subscription_cancel'|'payment_method_update')} FlowType
|
|
35
|
+
* The type of flow for the session.
|
|
36
|
+
* @property {string} subscription_update - Update a subscription.
|
|
37
|
+
* @property {string} subscription_cancel - Cancel a subscription.
|
|
38
|
+
* @property {string} payment_method_update - Update a payment method.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a billing portal session using the Stripe API.
|
|
43
|
+
* Supports 3 types of flows:
|
|
44
|
+
* 1. Payment method update
|
|
45
|
+
* 2. Subscription update
|
|
46
|
+
* 3. Subscription cancel
|
|
47
|
+
* @param {Object} stripe - The Stripe instance
|
|
48
|
+
* @param {Object} props - Properties required to create the billing portal session.
|
|
49
|
+
* @param {string} props.customer - Customer ID
|
|
50
|
+
* @param {string} props.return_url - Return URL to be directed to
|
|
51
|
+
* @param {FlowType} [props.flow_type] - Flow type. Defaults to null.
|
|
52
|
+
* @param {string} [props.subscriptionId] - Subscription ID to be updated or cancelled.
|
|
53
|
+
* @returns {Promise<Object>} The created billing portal session.
|
|
54
|
+
* @example
|
|
55
|
+
* const session = await createBillingPortalSession(stripe, {
|
|
56
|
+
* customer: 'cus_123456789',
|
|
57
|
+
* return_url: 'https://example.com/return',
|
|
58
|
+
* flow_type: 'subscription_update',
|
|
59
|
+
* subscriptionId: 'sub_123456789'
|
|
60
|
+
* });
|
|
61
|
+
*/
|
|
62
|
+
export async function createBillingPortalSession(stripe, props = {}) {
|
|
63
|
+
const {
|
|
64
|
+
customer,
|
|
65
|
+
return_url,
|
|
66
|
+
flow_type = null,
|
|
67
|
+
subscriptionId = null,
|
|
68
|
+
} = props
|
|
69
|
+
|
|
70
|
+
const bpData = {
|
|
71
|
+
customer,
|
|
72
|
+
return_url,
|
|
73
|
+
flow_data: {},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (flow_type) bpData.flow_data.type = flow_type
|
|
77
|
+
|
|
78
|
+
// For subscription update and cancel
|
|
79
|
+
if (
|
|
80
|
+
flow_type === 'subscription_update' ||
|
|
81
|
+
flow_type === 'subscription_cancel'
|
|
82
|
+
) {
|
|
83
|
+
if (!subscriptionId) throw new Error('Subscription ID is required')
|
|
84
|
+
bpData.flow_data[flow_type] = { subscription: subscriptionId }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return stripe.billingPortal.sessions.create(omitEmpty(bpData))
|
|
88
|
+
}
|