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) {