diff --git a/.vscode/settings.json b/.vscode/settings.json index c2627ef6..c6761272 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,8 @@ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": "explicit" + }, + "[svelte]": { + "editor.defaultFormatter": "svelte.svelte-vscode" } } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 6a0d7f0c..097d6f33 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -55,6 +55,7 @@ "openai-edge": "^1.2.2", "papaparse": "^5.4.1", "pluralize": "^8.0.0", + "polar-sveltekit": "^0.0.4", "posthog-js": "^1.180.1", "shared": "workspace:*", "sirv": "^2.0.3", diff --git a/apps/dashboard/src/lib/components/Upgrade/Modal.svelte b/apps/dashboard/src/lib/components/Upgrade/Modal.svelte index 79d9c4e0..a6b4acac 100644 --- a/apps/dashboard/src/lib/components/Upgrade/Modal.svelte +++ b/apps/dashboard/src/lib/components/Upgrade/Modal.svelte @@ -1,32 +1,30 @@ {#if upgraded} @@ -126,12 +109,12 @@ {onClose} {open} width="w-4/5" - maxWidth={upgraded ? 'max-w-[600px]' : undefined} + maxWidth={upgraded || isConfirming ? 'max-w-[600px]' : undefined} modalHeading={$t('pricing.modal.heading')} containerClass="pt-4" > {#if upgraded} -
+

{$t('pricing.modal.thanks')}

@@ -150,6 +133,10 @@ />

+ {:else if isConfirming} +
+ +
{:else}
@@ -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