From 08e661e16c237b77afe919aeb26a1060122eafec Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Tue, 12 Sep 2023 17:56:16 +0100 Subject: [PATCH] Make customer portal URL automatically logged in --- lib/providers/stripe.ts | 42 +++++++++++++++++++++++-- lib/utils.ts | 14 +++++++-- lib/utils_test.ts | 70 +++++++++++++++++++++++++++++++++++++++-- pages/api/user.ts | 25 +++++++++++++-- public/ts/billing.ts | 27 ++-------------- public/ts/pricing.ts | 8 +++-- public/ts/utils.ts | 4 +-- 7 files changed, 152 insertions(+), 38 deletions(-) diff --git a/lib/providers/stripe.ts b/lib/providers/stripe.ts index 15abf96..5a91b70 100644 --- a/lib/providers/stripe.ts +++ b/lib/providers/stripe.ts @@ -1,5 +1,7 @@ import 'std/dotenv/load.ts'; +import { baseUrl, jsonToFormUrlEncoded } from '/lib/utils.ts'; + const STRIPE_API_KEY = Deno.env.get('STRIPE_API_KEY') || ''; interface StripeCustomer { @@ -60,11 +62,23 @@ interface StripeResponse { data: any[]; } -function getApiRequestHeaders() { +interface StripeCustomerPortalSession { + id: string; + object: 'billing_portal.session'; + configuration: string; + created: number; + customer: string; + return_url: string; + url: string; +} + +function getApiRequestHeaders(method: 'GET' | 'POST') { return { 'Authorization': `Bearer ${STRIPE_API_KEY}`, 'Accept': 'application/json; charset=utf-8', - 'Content-Type': 'application/json; charset=utf-8', + 'Content-Type': method === 'GET' + ? 'application/json; charset=utf-8' + : 'application/x-www-form-urlencoded; charset=utf-8', }; } @@ -76,7 +90,7 @@ export async function getSubscriptions() { const response = await fetch(`https://api.stripe.com/v1/subscriptions?${searchParams.toString()}`, { method: 'GET', - headers: getApiRequestHeaders(), + headers: getApiRequestHeaders('GET'), }); const result = (await response.json()) as StripeResponse; @@ -90,3 +104,25 @@ export async function getSubscriptions() { return subscriptions; } + +export async function createCustomerPortalSession(stripeCustomerId: string) { + const customerPortalSession = { + customer: stripeCustomerId, + return_url: baseUrl, + }; + + const response = await fetch(`https://api.stripe.com/v1/billing_portal/sessions`, { + method: 'POST', + headers: getApiRequestHeaders('POST'), + body: jsonToFormUrlEncoded(customerPortalSession), + }); + + const result = (await response.json()) as StripeCustomerPortalSession; + + if (!result?.url) { + console.log(JSON.stringify({ result }, null, 2)); + throw new Error(`Failed to make API request: "${result}"`); + } + + return result.url; +} diff --git a/lib/utils.ts b/lib/utils.ts index 2c9b625..57f5135 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -72,8 +72,6 @@ function basicLayout(htmlContent: string, { currentPath, titlePrefix, descriptio window.app = { STRIPE_MONTHLY_URL: '${STRIPE_MONTHLY_URL}', STRIPE_YEARLY_URL: '${STRIPE_YEARLY_URL}', - STRIPE_CUSTOMER_URL: '${STRIPE_CUSTOMER_URL}', - PAYPAL_CUSTOMER_URL: '${PAYPAL_CUSTOMER_URL}', }; @@ -185,3 +183,15 @@ export function splitArrayInChunks(array: T[], chunkLength: number) { return chunks; } + +// Because new URLSearchParams(Object.entries(object)).toString() doesn't work recursively +export function jsonToFormUrlEncoded(object: any, key = '', list: string[] = []) { + if (typeof (object) === 'object') { + for (const subKey in object) { + jsonToFormUrlEncoded(object[subKey], key ? `${key}[${subKey}]` : subKey, list); + } + } else { + list.push(`${key}=${encodeURIComponent(object)}`); + } + return list.join('&'); +} diff --git a/lib/utils_test.ts b/lib/utils_test.ts index fbac83c..af1029a 100644 --- a/lib/utils_test.ts +++ b/lib/utils_test.ts @@ -1,5 +1,5 @@ -import { assertEquals } from 'std/testing/asserts.ts'; -import { escapeHtml, generateRandomCode, splitArrayInChunks } from './utils.ts'; +import { assertEquals } from 'std/assert/assert_equals.ts'; +import { escapeHtml, generateRandomCode, jsonToFormUrlEncoded, splitArrayInChunks } from './utils.ts'; Deno.test('that escapeHtml works', () => { const tests = [ @@ -102,3 +102,69 @@ Deno.test('that splitArrayInChunks works', () => { assertEquals(output, test.expected); } }); + +Deno.test('that jsonToFormUrlEncoded works', () => { + const tests = [ + { + input: { + user: { + id: 'uuid-1', + role: 'user', + groups: [ + { + id: 'uuid-1', + permissions: ['view_banking', 'edit_banking'], + }, + { + id: 'uuid-2', + permissions: ['view_employees'], + }, + ], + }, + permissions: ['view_banking'], + }, + expected: + 'user[id]=uuid-1&user[role]=user&user[groups][0][id]=uuid-1&user[groups][0][permissions][0]=view_banking&user[groups][0][permissions][1]=edit_banking&user[groups][1][id]=uuid-2&user[groups][1][permissions][0]=view_employees&permissions[0]=view_banking', + }, + { + input: { + user: { + id: 'uuid-1', + role: 'user', + groups: ['all', 'there'], + }, + permissions: ['view_employees'], + }, + expected: + 'user[id]=uuid-1&user[role]=user&user[groups][0]=all&user[groups][1]=there&permissions[0]=view_employees', + }, + { + input: { + user: { + id: 'uuid-1', + role: 'admin', + groups: [], + }, + permissions: ['edit_banking'], + }, + expected: 'user[id]=uuid-1&user[role]=admin&permissions[0]=edit_banking', + }, + { + input: [{ something: 1 }], + expected: '0[something]=1', + }, + { + input: { something: 1 }, + expected: 'something=1', + }, + { + input: { something: [1, 2] }, + expected: 'something[0]=1&something[1]=2', + }, + ]; + + for (const test of tests) { + const output = jsonToFormUrlEncoded(test.input); + assertEquals(output, test.expected); + } +}); diff --git a/pages/api/user.ts b/pages/api/user.ts index 0a5abef..077b76a 100644 --- a/pages/api/user.ts +++ b/pages/api/user.ts @@ -11,7 +11,7 @@ import { validateUserAndSession, validateVerificationCode, } from '/lib/data-utils.ts'; -import { EncryptedData } from '/lib/types.ts'; +import { EncryptedData, User } from '/lib/types.ts'; import { sendUpdateEmailInProviderEmail, sendVerifyDeleteAccountEmail, @@ -19,6 +19,8 @@ import { sendVerifyUpdatePasswordEmail, } from '/lib/providers/postmark.ts'; import { SupportedCurrencySymbol, validateEmail } from '/public/ts/utils.ts'; +import { PAYPAL_CUSTOMER_URL, STRIPE_CUSTOMER_URL } from '/lib/utils.ts'; +import { createCustomerPortalSession } from '/lib/providers/stripe.ts'; async function createUserAction(request: Request) { const { email, encrypted_key_pair }: { email: string; encrypted_key_pair: EncryptedData } = await request.json(); @@ -190,5 +192,24 @@ export async function pageContent(request: Request) { await updateSession(session); - return new Response(JSON.stringify(user), { headers: { 'Content-Type': 'application/json; charset=utf-8' } }); + let customerPortalUrl = ''; + + if (user.subscription.external.paypal) { + customerPortalUrl = PAYPAL_CUSTOMER_URL; + } else if (user.subscription.external.stripe) { + customerPortalUrl = STRIPE_CUSTOMER_URL; + + if (user.subscription.external.stripe.user_id) { + try { + customerPortalUrl = await createCustomerPortalSession(user.subscription.external.stripe.user_id); + } catch (error) { + console.error(`Failed to create custom Stripe customer portal URL: ${error}`); + console.log(error); + } + } + } + + const finalResult: User & { customerPortalUrl: string } = { ...user, customerPortalUrl }; + + return new Response(JSON.stringify(finalResult), { headers: { 'Content-Type': 'application/json; charset=utf-8' } }); } diff --git a/public/ts/billing.ts b/public/ts/billing.ts index f542dac..4feb389 100644 --- a/public/ts/billing.ts +++ b/public/ts/billing.ts @@ -11,14 +11,7 @@ document.addEventListener('app-loaded', async () => { event.preventDefault(); event.stopPropagation(); - let updateUrl = ''; - - if (user?.subscription.external.stripe) { - updateUrl = window.app.STRIPE_CUSTOMER_URL; - } - if (user?.subscription.external.paypal) { - updateUrl = window.app.PAYPAL_CUSTOMER_URL; - } + const updateUrl = user?.customerPortalUrl; if (!updateUrl) { showNotification('You need to reach out in order to update your subscription, sorry!', 'error'); @@ -32,14 +25,7 @@ document.addEventListener('app-loaded', async () => { event.preventDefault(); event.stopPropagation(); - let cancelUrl = ''; - - if (user?.subscription.external.stripe) { - cancelUrl = window.app.STRIPE_CUSTOMER_URL; - } - if (user?.subscription.external.paypal) { - cancelUrl = window.app.PAYPAL_CUSTOMER_URL; - } + const cancelUrl = user?.customerPortalUrl; if (!cancelUrl) { showNotification('You need to reach out in order to cancel your subscription, sorry!', 'error'); @@ -53,14 +39,7 @@ document.addEventListener('app-loaded', async () => { event.preventDefault(); event.stopPropagation(); - let updateUrl = ''; - - if (user?.subscription.external.stripe) { - updateUrl = window.app.STRIPE_CUSTOMER_URL; - } - if (user?.subscription.external.paypal) { - updateUrl = window.app.PAYPAL_CUSTOMER_URL; - } + const updateUrl = user?.customerPortalUrl; if (!updateUrl) { showNotification('You need to reach out in order to resume your subscription, sorry!', 'error'); diff --git a/public/ts/pricing.ts b/public/ts/pricing.ts index dca40ea..8dfd6e1 100644 --- a/public/ts/pricing.ts +++ b/public/ts/pricing.ts @@ -45,7 +45,9 @@ document.addEventListener('app-loaded', async () => { return; } - window.location.href = window.app.STRIPE_MONTHLY_URL; + window.location.href = `${window.app.STRIPE_MONTHLY_URL}?client_reference_id=${user.id}&customer_email=${ + encodeURIComponent(user.email) + }`; } async function subscribeYearly(event: Event) { @@ -59,7 +61,9 @@ document.addEventListener('app-loaded', async () => { return; } - window.location.href = window.app.STRIPE_YEARLY_URL; + window.location.href = `${window.app.STRIPE_YEARLY_URL}?client_reference_id=${user.id}&customer_email=${ + encodeURIComponent(user.email) + }`; } function getValidSubscriptionHtmlElement() { diff --git a/public/ts/utils.ts b/public/ts/utils.ts index 215ef11..43336a2 100644 --- a/public/ts/utils.ts +++ b/public/ts/utils.ts @@ -12,8 +12,6 @@ declare global { export interface App { STRIPE_MONTHLY_URL: string; STRIPE_YEARLY_URL: string; - STRIPE_CUSTOMER_URL: string; - PAYPAL_CUSTOMER_URL: string; isLoggedIn: boolean; showLoading: () => void; hideLoading: () => void; @@ -154,7 +152,7 @@ async function getUser() { searchParams.set('email', session.email); const response = await fetch(`/api/user?${searchParams.toString()}`, { method: 'GET', headers }); - const user = (await response.json()) as User; + const user = (await response.json()) as User & { customerPortalUrl: string }; return user; } catch (_error) {