@@ -157,7 +144,7 @@
style="background-color: {isYearlyPlan ? 'initial' : '#1D4EE2'}; color: {isYearlyPlan
? '#5e636b'
: '#fff'}"
- class="rounded-[30px] bg-blue-700 px-3 py-1 text-xs text-white lg:px-4 lg:py-2 transition-all duration-500 ease-in-out"
+ class="rounded-[30px] bg-blue-700 px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
on:click={toggleIsYearlyPlan}
>
{$t('pricing.modal.monthly')}
@@ -166,12 +153,12 @@
style="background-color: {isYearlyPlan ? '#1D4EE2' : ''}; color: {isYearlyPlan
? '#fff'
: '#5e636b'}"
- class="relative rounded-[30px] px-3 py-1 text-xs text-white lg:px-4 lg:py-2 transition-all duration-500 ease-in-out"
+ class="relative rounded-[30px] px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
on:click={toggleIsYearlyPlan}
>
{$t('pricing.modal.annually')}
{$t('pricing.modal.save')}
@@ -213,7 +200,7 @@
? 'bg-white text-slate-900 hover:bg-indigo-50'
: PLANS[planName].CTA.IS_DISABLED
? disabledClass
- : 'bg-primary-900 hover:bg-primary-700 text-white'} py-3 text-center font-medium hover:no-underline lg:rounded-md lg:py-3 lg:text-lg lg:font-semibold flex items-center justify-center"
+ : 'bg-primary-900 hover:bg-primary-700 text-white'} flex items-center justify-center py-3 text-center font-medium hover:no-underline lg:rounded-md lg:py-3 lg:text-lg lg:font-semibold"
on:click={() => {
if (isLoadingPlan === planName) return;
diff --git a/apps/dashboard/src/lib/utils/services/org/index.ts b/apps/dashboard/src/lib/utils/services/org/index.ts
index e3d8859f..c68fb4c2 100644
--- a/apps/dashboard/src/lib/utils/services/org/index.ts
+++ b/apps/dashboard/src/lib/utils/services/org/index.ts
@@ -77,7 +77,7 @@ export async function getOrganizations(userId: string, isOrgSite?: boolean, orgS
organization_plan(
plan_name,
is_active,
- subscriptionId:lmz_data->id
+ subscriptionId:payload->id
)
)
`
@@ -255,11 +255,27 @@ export async function getCurrentOrg(siteName: string, justGet = false, isCustomD
}
}
+export async function updateOrgPlan(params: {
+ supabase: typeof supabase;
+ subscriptionId: string;
+ data: OrganizationPlan['payload'];
+}) {
+ return await params.supabase
+ .from('organization_plan')
+ .update({
+ payload: params.data
+ })
+ .match({
+ subscription_id: params.subscriptionId
+ });
+}
+
export async function createOrgPlan(params: {
- orgId: string;
- planName: string;
- triggeredBy: number;
- data: OrganizationPlan['lmz_data'];
+ orgId: OrganizationPlan['org_id'];
+ planName: OrganizationPlan['plan_name'];
+ subscriptionId: OrganizationPlan['subscription_id'];
+ triggeredBy: OrganizationPlan['triggered_by'];
+ data: OrganizationPlan['payload'];
supabase: typeof supabase;
}) {
return await params.supabase.from('organization_plan').insert({
@@ -268,19 +284,24 @@ export async function createOrgPlan(params: {
triggered_by: params.triggeredBy,
plan_name: params.planName,
is_active: true,
- lmz_data: params.data
+ payload: params.data,
+ subscription_id: params.subscriptionId,
+ provider: 'polar'
});
}
-export async function cancelOrgPlan(params: { orgId: string; planName: string }) {
+export async function cancelOrgPlan(params: {
+ subscriptionId: string;
+ data: OrganizationPlan['payload'];
+}) {
return await supabase
.from('organization_plan')
.update({
is_active: false,
- deactivated_at: new Date().toDateString()
+ deactivated_at: new Date().toDateString(),
+ payload: params.data
})
.match({
- plan_name: params.planName,
- org_id: params.orgId
+ subscription_id: params.subscriptionId
});
}
diff --git a/apps/dashboard/src/lib/utils/types/index.ts b/apps/dashboard/src/lib/utils/types/index.ts
index 6bb6e1dd..1962d42c 100644
--- a/apps/dashboard/src/lib/utils/types/index.ts
+++ b/apps/dashboard/src/lib/utils/types/index.ts
@@ -136,10 +136,13 @@ export interface OrganizationPlan {
activated_at: string;
deactivated_at: string;
org_id: string;
+ subscription_id: string;
+ triggered_by: number;
plan_name: string;
is_active: boolean;
updated_at: string;
- lmz_data: unknown;
+ payload: unknown;
+ provider: string;
}
export interface Profile {
diff --git a/apps/dashboard/src/lib/utils/types/polar.ts b/apps/dashboard/src/lib/utils/types/polar.ts
new file mode 100644
index 00000000..d1a23737
--- /dev/null
+++ b/apps/dashboard/src/lib/utils/types/polar.ts
@@ -0,0 +1,120 @@
+interface BillingAddress {
+ line1: string | null;
+ line2: string | null;
+ postalCode: string | null;
+ city: string | null;
+ state: string | null;
+ country: string;
+}
+
+interface Customer {
+ createdAt: string;
+ modifiedAt: string | null;
+ id: string;
+ metadata: Record
;
+ email: string;
+ emailVerified: boolean;
+ name: string;
+ billingAddress: BillingAddress;
+ taxId: string | null;
+ organizationId: string;
+ avatarUrl: string;
+}
+
+interface User {
+ id: string;
+ email: string;
+ publicName: string;
+ avatarUrl: string | null;
+ githubUsername: string | null;
+}
+
+interface Price {
+ createdAt: string;
+ modifiedAt: string | null;
+ id: string;
+ amountType: string;
+ isArchived: boolean;
+ productId: string;
+ priceCurrency: string;
+ priceAmount: number;
+ type: string;
+ recurringInterval: string;
+}
+
+interface Media {
+ id: string;
+ organizationId: string;
+ name: string;
+ path: string;
+ mimeType: string;
+ size: number;
+ storageVersion: string;
+ checksumEtag: string;
+ checksumSha256Base64: string;
+ checksumSha256Hex: string;
+ lastModifiedAt: string;
+ version: string | null;
+ service: string;
+ isUploaded: boolean;
+ createdAt: string;
+ sizeReadable: string;
+ publicUrl: string;
+}
+
+interface Product {
+ createdAt: string;
+ modifiedAt: string;
+ id: string;
+ name: string;
+ description: string;
+ isRecurring: boolean;
+ isArchived: boolean;
+ organizationId: string;
+ metadata: Record;
+ prices: Price[];
+ benefits: any[];
+ medias: Media[];
+ attachedCustomFields: any[];
+}
+
+interface SubscriptionData {
+ createdAt: string;
+ modifiedAt: string | null;
+ id: string;
+ amount: number;
+ currency: string;
+ recurringInterval: string;
+ status: string;
+ currentPeriodStart: string;
+ currentPeriodEnd: string;
+ cancelAtPeriodEnd: boolean;
+ canceledAt: string | null;
+ startedAt: string;
+ endsAt: string | null;
+ endedAt: string | null;
+ customerId: string;
+ productId: string;
+ priceId: string;
+ discountId: string | null;
+ checkoutId: string;
+ customerCancellationReason: string | null;
+ customerCancellationComment: string | null;
+ metadata: {
+ orgId: string;
+ orgSlug: string;
+ triggeredBy: string;
+ };
+ customFieldData: Record;
+ customer: Customer;
+ userId: string;
+ user: User;
+ product: Product;
+ price: Price;
+ discount: any | null;
+}
+
+export interface PolarWebhookPayload {
+ type: string;
+ data: SubscriptionData;
+}
diff --git a/apps/dashboard/src/routes/+layout.server.ts b/apps/dashboard/src/routes/+layout.server.ts
index 1566699e..dbbbc6c3 100644
--- a/apps/dashboard/src/routes/+layout.server.ts
+++ b/apps/dashboard/src/routes/+layout.server.ts
@@ -21,6 +21,8 @@ interface LoadOutput {
serverLang: string;
}
+const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS.split(',');
+
export const load = async ({ url, cookies, request }): Promise => {
const response: LoadOutput = {
orgSiteName: '',
@@ -31,6 +33,8 @@ export const load = async ({ url, cookies, request }): Promise => {
serverLang: request.headers?.get('accept-language') || ''
};
+ console.log('IS_SELFHOSTED', IS_SELFHOSTED);
+
// Selfhosted usecase would be here
if (IS_SELFHOSTED === 'true') {
const subdomain = getSubdomain(url);
@@ -72,7 +76,7 @@ export const load = async ({ url, cookies, request }): Promise => {
const isDev = dev || isLocalHost;
- if (!url.host.includes('.classroomio.com') && !isLocalHost) {
+ if (isURLCustomDomain(url)) {
// Custom domain
response.org = (await getCurrentOrg(url.host, true, true)) || null;
@@ -86,8 +90,15 @@ export const load = async ({ url, cookies, request }): Promise => {
response.orgSiteName = response.org?.siteName || '';
return response;
} else if (!blockedSubdomain.includes(subdomain)) {
+ if (APP_SUBDOMAINS.includes(subdomain)) {
+ // This is an app domain specified in the .env file
+ return response;
+ }
+
const answer = !!subdomain;
+ console.log('subdomain', subdomain);
+
response.isOrgSite = debugMode || answer;
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
response.org = (await getCurrentOrg(response.orgSiteName, true)) || null;
@@ -99,7 +110,7 @@ export const load = async ({ url, cookies, request }): Promise => {
}
} else if (subdomain === 'play' || debugPlay === 'true') {
response.skipAuth = true;
- } else if (!env.PRIVATE_APP_SUBDOMAINS.split(',').includes(subdomain) && !isDev) {
+ } else if (!APP_SUBDOMAINS.includes(subdomain) && !isDev) {
// This case is for anything in our blockedSubdomains
throw redirect(307, 'https://app.classroomio.com');
}
@@ -107,6 +118,18 @@ export const load = async ({ url, cookies, request }): Promise => {
return response;
};
+function isURLCustomDomain(url: URL) {
+ if (url.host.includes('localhost')) {
+ return false;
+ }
+
+ const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'vercel.app'].filter(
+ Boolean
+ );
+
+ return !notCustomDomainHosts.some((host) => url.host.endsWith(host));
+}
+
function getBaseMetaTags(url: URL) {
return Object.freeze({
title: 'ClassroomIO | The Open Source Learning Management System for Companies',
@@ -145,8 +168,6 @@ function getBaseMetaTags(url: URL) {
});
}
-
-
function getSubdomain(url: URL) {
const host = url.host.replace('www.', '');
const parts = host.split('.');
diff --git a/apps/dashboard/src/routes/api/lmz/webhook/+server.js b/apps/dashboard/src/routes/api/lmz/webhook/+server.js
index 3c208962..a5b005cb 100644
--- a/apps/dashboard/src/routes/api/lmz/webhook/+server.js
+++ b/apps/dashboard/src/routes/api/lmz/webhook/+server.js
@@ -1,9 +1,9 @@
+import { env } from '$env/dynamic/private';
+import { getServerSupabase } from '$lib/utils/functions/supabase.server';
+import { cancelOrgPlan, createOrgPlan } from '$lib/utils/services/org';
import { json } from '@sveltejs/kit';
import crypto from 'crypto';
-import { env } from '$env/dynamic/private';
-import { createOrgPlan, cancelOrgPlan } from '$lib/utils/services/org';
import { PLAN } from 'shared/src/plans/constants';
-import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export async function POST({ request }) {
try {
@@ -30,10 +30,11 @@ export async function POST({ request }) {
if (eventType === 'subscription_created' || eventType === 'subscription_resumed') {
const orgId = body.meta.custom_data.org_id;
const triggeredBy = body.meta.custom_data.triggered_by;
+ const subscriptionId = body.data.id;
const isSuccessful = body.data.attributes.status === 'active';
console.log({
- orgId,
+ subscriptionId,
triggeredBy,
isSuccessful
});
@@ -41,6 +42,7 @@ export async function POST({ request }) {
if (isSuccessful && orgId) {
const { data, error } = await createOrgPlan({
supabase,
+ subscriptionId,
orgId,
triggeredBy: parseInt(triggeredBy),
planName: PLAN.EARLY_ADOPTER,
@@ -57,10 +59,10 @@ export async function POST({ request }) {
`${eventType}`
)
) {
- const orgId = body.meta.custom_data.org_id;
+ const subscriptionId = body.data.id;
const { data, error } = await cancelOrgPlan({
- orgId,
- planName: PLAN.EARLY_ADOPTER
+ subscriptionId,
+ data: body.data
});
if (error) console.error('Error canceling org plan', error);
diff --git a/apps/dashboard/src/routes/api/polar/subscribe/+server.ts b/apps/dashboard/src/routes/api/polar/subscribe/+server.ts
new file mode 100644
index 00000000..a51205cc
--- /dev/null
+++ b/apps/dashboard/src/routes/api/polar/subscribe/+server.ts
@@ -0,0 +1,14 @@
+import { env } from '$env/dynamic/private';
+import { Checkout } from 'polar-sveltekit';
+
+export const GET = ({ ...params }) => {
+ const metadata = JSON.parse(params.url.searchParams.get('metadata') ?? '{}');
+ const successUrl = `/org/${metadata.orgSlug}?upgrade=true&confirmation=true`;
+
+ const request = Checkout({
+ accessToken: env.POLAR_ACCESS_TOKEN,
+ successUrl
+ });
+
+ return request(params);
+};
diff --git a/apps/dashboard/src/routes/api/polar/webhook/+server.ts b/apps/dashboard/src/routes/api/polar/webhook/+server.ts
new file mode 100644
index 00000000..7c8e4d9c
--- /dev/null
+++ b/apps/dashboard/src/routes/api/polar/webhook/+server.ts
@@ -0,0 +1,86 @@
+import { env } from '$env/dynamic/private';
+import { getServerSupabase } from '$lib/utils/functions/supabase.server';
+import { cancelOrgPlan, createOrgPlan, updateOrgPlan } from '$lib/utils/services/org';
+import type { PolarWebhookPayload } from '$lib/utils/types/polar';
+import { Webhooks } from 'polar-sveltekit';
+import { PLAN } from 'shared/src/plans/constants';
+
+export const POST = Webhooks({
+ webhookSecret: env.POLAR_WEBHOOK_SECRET!,
+ onPayload
+});
+
+async function onPayload(payload: PolarWebhookPayload) {
+ const supabase = getServerSupabase();
+
+ const metadata = payload.data.metadata;
+ const orgId = metadata.orgId;
+ const triggeredBy = metadata.triggeredBy;
+ const subscriptionId = payload.data.id;
+ const isSubscriptionActive = payload.data.status === 'active';
+
+ console.log({
+ orgId,
+ triggeredBy,
+ isSubscriptionActive,
+ subscriptionId
+ });
+
+ if (!orgId) {
+ console.error('Org ID is required');
+ throw new Error('Org ID is required');
+ }
+
+ switch (payload.type) {
+ case 'checkout.created':
+ break;
+ case 'checkout.updated':
+ break;
+ case 'subscription.created':
+ if (isSubscriptionActive) {
+ const { data, error } = await createOrgPlan({
+ supabase,
+ subscriptionId,
+ orgId,
+ triggeredBy: parseInt(triggeredBy),
+ planName: PLAN.EARLY_ADOPTER,
+ data: payload.data
+ });
+
+ if (error) throw new Error('Error creating org plan');
+ console.log('Subscription created', data);
+ }
+ break;
+ case 'subscription.updated':
+ if (!isSubscriptionActive) {
+ // Handle Cancel Subscription
+ const { data, error } = await cancelOrgPlan({
+ subscriptionId,
+ data: payload.data
+ });
+
+ if (error) throw new Error('Error canceling org plan');
+ console.log('Subscription canceled', data);
+ } else {
+ // Handle Update Subscription
+ const { data, error } = await updateOrgPlan({
+ supabase,
+ subscriptionId,
+ data: payload.data
+ });
+
+ if (error) throw new Error('Error updating org plan');
+ console.log('Subscription updated', data);
+ }
+ break;
+ case 'subscription.active':
+ break;
+ case 'subscription.revoked':
+ break;
+ case 'subscription.canceled':
+ break;
+ default:
+ console.log('Unknown event', payload.type);
+ break;
+ }
+}
diff --git a/packages/shared/src/plans/data.json b/packages/shared/src/plans/data.json
index 31fceca0..02f19081 100644
--- a/packages/shared/src/plans/data.json
+++ b/packages/shared/src/plans/data.json
@@ -47,8 +47,8 @@
"DASHBOARD_LABEL": "Support us 😍",
"DASHBOARD_LINK": "",
"IS_DISABLED": false,
- "PRODUCT_ID": "331121",
- "PRODUCT_ID_YEARLY": "331122"
+ "PRODUCT_ID": "bb0c0593-3852-400a-a90a-f51fb35b51b7",
+ "PRODUCT_ID_YEARLY": "a84d3a82-3259-4300-b2bf-dccb7bd7814c"
}
},
"ENTERPRISE": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 349e46c8..e30814d8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -364,6 +364,9 @@ importers:
pluralize:
specifier: ^8.0.0
version: 8.0.0
+ polar-sveltekit:
+ specifier: ^0.0.4
+ version: 0.0.4(@sveltejs/kit@1.29.0)(zod@3.21.4)
posthog-js:
specifier: ^1.180.1
version: 1.180.1
@@ -4914,6 +4917,15 @@ packages:
playwright: 1.48.2
dev: true
+ /@polar-sh/sdk@0.20.0(zod@3.21.4):
+ resolution: {integrity: sha512-IsIRJgDYB06pcoj9pnC+ovV3gW8cSUb/YJeMbU72puUe++fadxvsMsoL0+r59m/Cmghl79duILG6bQ6JOxYNDg==}
+ peerDependencies:
+ zod: '>= 3'
+ dependencies:
+ standardwebhooks: 1.0.0
+ zod: 3.21.4
+ dev: false
+
/@polka/url@1.0.0-next.23:
resolution: {integrity: sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==}
@@ -6040,6 +6052,10 @@ packages:
tslib: 2.8.1
dev: false
+ /@stablelib/base64@1.0.1:
+ resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
+ dev: false
+
/@supabase/functions-js@2.1.5:
resolution: {integrity: sha512-BNzC5XhCzzCaggJ8s53DP+WeHHGT/NfTsx2wUSSGKR2/ikLFQTBCDzMvGz/PxYMqRko/LwncQtKXGOYp1PkPaw==}
dependencies:
@@ -6163,7 +6179,7 @@ packages:
peerDependencies:
'@sveltejs/kit': ^2.0.0
dependencies:
- '@sveltejs/kit': 2.10.1(@sveltejs/vite-plugin-svelte@5.0.0)(svelte@5.10.1)(vite@6.0.0)
+ '@sveltejs/kit': 2.10.1(@sveltejs/vite-plugin-svelte@4.0.0)(svelte@5.10.1)(vite@5.4.11)
import-meta-resolve: 4.1.0
dev: true
@@ -10778,6 +10794,10 @@ packages:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
dev: true
+ /fast-sha256@1.3.0:
+ resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
+ dev: false
+
/fast-xml-parser@4.2.5:
resolution: {integrity: sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g==}
hasBin: true
@@ -14962,6 +14982,18 @@ packages:
engines: {node: '>=10.13.0'}
dev: true
+ /polar-sveltekit@0.0.4(@sveltejs/kit@1.29.0)(zod@3.21.4):
+ resolution: {integrity: sha512-vlCbbBDJd8M6lqn6SfERSvH8xrozEGOJtpSFpygW/L1iJb8VpUEusymeD5IKdjZcqjNdhUaWh+Tbm0igYwTGoQ==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ '@sveltejs/kit': ^2.0.0
+ dependencies:
+ '@polar-sh/sdk': 0.20.0(zod@3.21.4)
+ '@sveltejs/kit': 1.29.0(svelte@4.1.2)(vite@4.4.8)
+ transitivePeerDependencies:
+ - zod
+ dev: false
+
/popmotion@11.0.5:
resolution: {integrity: sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==}
dependencies:
@@ -16523,6 +16555,13 @@ packages:
yargs: 16.2.0
dev: true
+ /standardwebhooks@1.0.0:
+ resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
+ dependencies:
+ '@stablelib/base64': 1.0.1
+ fast-sha256: 1.3.0
+ dev: false
+
/statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
diff --git a/supabase/migrations/20240406215136_org_billing.sql b/supabase/migrations/20240406215136_org_billing.sql
index 18f6e370..019ebf24 100644
--- a/supabase/migrations/20240406215136_org_billing.sql
+++ b/supabase/migrations/20240406215136_org_billing.sql
@@ -8,7 +8,7 @@ create table "public"."organization_plan" (
"is_active" boolean,
"deactivated_at" timestamp with time zone,
"updated_at" timestamp with time zone default now(),
- "lmz_data" jsonb,
+ "payload" jsonb,
"triggered_by" bigint
);
diff --git a/supabase/migrations/20250106020355_polar_setup.sql b/supabase/migrations/20250106020355_polar_setup.sql
new file mode 100644
index 00000000..66840934
--- /dev/null
+++ b/supabase/migrations/20250106020355_polar_setup.sql
@@ -0,0 +1,7 @@
+alter table "public"."organization_plan" add column "provider" text default 'lmz'::text;
+
+alter table "public"."organization_plan" add column "subscription_id" text not null;
+
+CREATE UNIQUE INDEX organization_plan_subscription_id_key ON public.organization_plan USING btree (subscription_id);
+
+alter table "public"."organization_plan" add constraint "organization_plan_subscription_id_key" UNIQUE using index "organization_plan_subscription_id_key";
\ No newline at end of file