Skip to content

Commit

Permalink
Make customer portal URL automatically logged in
Browse files Browse the repository at this point in the history
  • Loading branch information
BrunoBernardino committed Sep 12, 2023
1 parent 8b724d9 commit 08e661e
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 38 deletions.
42 changes: 39 additions & 3 deletions lib/providers/stripe.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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',
};
}

Expand All @@ -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;
Expand All @@ -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;
}
14 changes: 12 additions & 2 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
};
</script>
<script src="/public/js/script.js"></script>
Expand Down Expand Up @@ -185,3 +183,15 @@ export function splitArrayInChunks<T = any>(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('&');
}
70 changes: 68 additions & 2 deletions lib/utils_test.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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);
}
});
25 changes: 23 additions & 2 deletions pages/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import {
validateUserAndSession,
validateVerificationCode,
} from '/lib/data-utils.ts';
import { EncryptedData } from '/lib/types.ts';
import { EncryptedData, User } from '/lib/types.ts';
import {
sendUpdateEmailInProviderEmail,
sendVerifyDeleteAccountEmail,
sendVerifyUpdateEmailEmail,
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();
Expand Down Expand Up @@ -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' } });
}
27 changes: 3 additions & 24 deletions public/ts/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand Down
8 changes: 6 additions & 2 deletions public/ts/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down
4 changes: 1 addition & 3 deletions public/ts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 08e661e

Please sign in to comment.