From 4936b546cc5548fb25e72c9b942a9e92919aa9a0 Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sat, 8 Aug 2026 21:49:42 +0530 Subject: [PATCH 01/11] feat(website): version pricing catalogs and preserve grandfathered purchases Move Paddle and Polar pricing data into source-controlled sandbox and production catalogs with active and legacy offerings. Update the website to show the new five-plan catalog, retire cloud lifetime, use dynamic yearly savings, and resolve Polar product and price identities correctly. Persist provider identities on purchases and lazily backfill active legacy purchases so existing subscriptions and lifetime customers remain associated with their original provider pricing. Add the Drizzle-generated forward migration and update checkout, provisioning, and webhook handling without modifying historical migrations. --- .../migrations/0008_yielding_carnage.sql | 4 + .../migrations/meta/0008_snapshot.json | 339 ++++++++++++++++ .../app/drizzle/migrations/meta/_journal.json | 7 + apps/website/app/drizzle/schema.server.ts | 9 + apps/website/app/lib/components/Pricing.tsx | 67 ++- apps/website/app/lib/config.server.ts | 59 +-- apps/website/app/lib/payment-catalog.ts | 381 ++++++++++++++++++ apps/website/app/lib/pricing-config.ts | 25 -- apps/website/app/lib/provisioning.server.ts | 50 ++- apps/website/app/lib/utilities.server.ts | 114 +++++- apps/website/app/routes/me.tsx | 36 +- apps/website/app/routes/paddle-webhook.tsx | 4 + apps/website/app/routes/polar-webhook.tsx | 26 +- 13 files changed, 956 insertions(+), 165 deletions(-) create mode 100644 apps/website/app/drizzle/migrations/0008_yielding_carnage.sql create mode 100644 apps/website/app/drizzle/migrations/meta/0008_snapshot.json create mode 100644 apps/website/app/lib/payment-catalog.ts delete mode 100644 apps/website/app/lib/pricing-config.ts diff --git a/apps/website/app/drizzle/migrations/0008_yielding_carnage.sql b/apps/website/app/drizzle/migrations/0008_yielding_carnage.sql new file mode 100644 index 00000000000..eeb919f7758 --- /dev/null +++ b/apps/website/app/drizzle/migrations/0008_yielding_carnage.sql @@ -0,0 +1,4 @@ +ALTER TABLE "customer_purchase" ADD COLUMN "payment_provider" "payment_provider";--> statement-breakpoint +ALTER TABLE "customer_purchase" ADD COLUMN "provider_price_id" text;--> statement-breakpoint +ALTER TABLE "customer_purchase" ADD COLUMN "provider_product_id" text;--> statement-breakpoint +CREATE INDEX "customer_purchase_provider_lookup_idx" ON "customer_purchase" USING btree ("payment_provider","provider_price_id","provider_product_id","cancelled_on"); \ No newline at end of file diff --git a/apps/website/app/drizzle/migrations/meta/0008_snapshot.json b/apps/website/app/drizzle/migrations/meta/0008_snapshot.json new file mode 100644 index 00000000000..4ab5426fca3 --- /dev/null +++ b/apps/website/app/drizzle/migrations/meta/0008_snapshot.json @@ -0,0 +1,339 @@ +{ + "id": "3b1186cd-5497-44b3-88f1-a6833e5aef40", + "prevId": "30429509-d778-437f-815b-c8ac7d996221", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contact_submission": { + "name": "contact_submission", + "schema": "", + "columns": { + "is_spam": { + "name": "is_spam", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_addressed": { + "name": "is_addressed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ticket_number": { + "name": "ticket_number", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer_purchase": { + "name": "customer_purchase", + "schema": "", + "columns": { + "plan_type": { + "name": "plan_type", + "type": "plan_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "product_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_provider": { + "name": "payment_provider", + "type": "payment_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "provider_price_id": { + "name": "provider_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_product_id": { + "name": "provider_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "renew_on": { + "name": "renew_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_on": { + "name": "cancelled_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_on": { + "name": "created_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_on": { + "name": "updated_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "customer_purchase_customer_id_idx": { + "name": "customer_purchase_customer_id_idx", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "customer_purchase_provider_lookup_idx": { + "name": "customer_purchase_provider_lookup_idx", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cancelled_on", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "customer_purchase_customer_id_customer_id_fk": { + "name": "customer_purchase_customer_id_customer_id_fk", + "tableFrom": "customer_purchase", + "tableTo": "customer", + "columnsFrom": ["customer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customer": { + "name": "customer", + "schema": "", + "columns": { + "unkey_key_id": { + "name": "unkey_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ryot_user_id": { + "name": "ryot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_issuer_id": { + "name": "oidc_issuer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "paddle_customer_id": { + "name": "paddle_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "polar_customer_id": { + "name": "polar_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_provider": { + "name": "payment_provider", + "type": "payment_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'paddle'" + }, + "created_on": { + "name": "created_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "customer_email_unique": { + "name": "customer_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "customer_oidc_issuer_id_unique": { + "name": "customer_oidc_issuer_id_unique", + "nullsNotDistinct": false, + "columns": ["oidc_issuer_id"] + }, + "customer_paddle_customer_id_unique": { + "name": "customer_paddle_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["paddle_customer_id"] + }, + "customer_polar_customer_id_unique": { + "name": "customer_polar_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["polar_customer_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.payment_provider": { + "name": "payment_provider", + "schema": "public", + "values": ["paddle", "polar"] + }, + "public.plan_type": { + "name": "plan_type", + "schema": "public", + "values": ["free", "monthly", "yearly", "lifetime"] + }, + "public.product_type": { + "name": "product_type", + "schema": "public", + "values": ["cloud", "self_hosted"] + } + }, + "schemas": {}, + "sequences": { + "public.ticket_number_seq": { + "name": "ticket_number_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/website/app/drizzle/migrations/meta/_journal.json b/apps/website/app/drizzle/migrations/meta/_journal.json index d71c11f2b03..a29384bf722 100644 --- a/apps/website/app/drizzle/migrations/meta/_journal.json +++ b/apps/website/app/drizzle/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1770478294981, "tag": "0007_broken_dormammu", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1786205197919, + "tag": "0008_yielding_carnage", + "breakpoints": true } ] } diff --git a/apps/website/app/drizzle/schema.server.ts b/apps/website/app/drizzle/schema.server.ts index 0165ed8049a..95c27b62a86 100644 --- a/apps/website/app/drizzle/schema.server.ts +++ b/apps/website/app/drizzle/schema.server.ts @@ -73,6 +73,9 @@ export const customerPurchases = pgTable( planType: planTypes("plan_type").notNull(), productType: productTypes("product_type").notNull(), id: uuid("id").notNull().primaryKey().defaultRandom(), + paymentProvider: paymentProviders("payment_provider"), + providerPriceId: text("provider_price_id"), + providerProductId: text("provider_product_id"), renewOn: timestamp("renew_on", { withTimezone: true }), cancelledOn: timestamp("cancelled_on", { withTimezone: true }), customerId: uuid("customer_id") @@ -89,5 +92,11 @@ export const customerPurchases = pgTable( customerIdIdx: index("customer_purchase_customer_id_idx").on( table.customerId, ), + providerLookupIdx: index("customer_purchase_provider_lookup_idx").on( + table.paymentProvider, + table.providerPriceId, + table.providerProductId, + table.cancelledOn, + ), }), ); diff --git a/apps/website/app/lib/components/Pricing.tsx b/apps/website/app/lib/components/Pricing.tsx index ca57c92484a..156b5ae9387 100644 --- a/apps/website/app/lib/components/Pricing.tsx +++ b/apps/website/app/lib/components/Pricing.tsx @@ -10,6 +10,7 @@ import { import { useState } from "react"; import { Link } from "react-router"; import { $path } from "safe-routes"; +import type { TPlanTypes, TProductTypes } from "~/drizzle/schema.server"; import type { TPrices } from "../config.server"; import { getIcon, getIconBg, isPopular } from "./pricing-utils"; import { Badge } from "./ui/badge"; @@ -19,14 +20,31 @@ import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; export default function Pricing(props: { prices: TPrices; isLoggedIn?: boolean; - onClick?: (priceId: string) => void; + onClick?: ( + priceId: string, + productType: TProductTypes, + planType: TPlanTypes, + ) => void; }) { const [selectedProductTypeIndex, setSelectedProductTypeIndex] = useState(0); const selectedProductType = props.prices[selectedProductTypeIndex]; - const isThreeColumn = selectedProductType.prices.length === 3; + const priceCount = selectedProductType.prices.length; const isCloudType = selectedProductType.type === "cloud"; const isSelfHosted = selectedProductType.type === "self_hosted"; + const isLargeCardLayout = priceCount < 4; + const monthlyAmount = selectedProductType.prices.find( + (price) => price.name === "monthly", + )?.amount; + const yearlyAmount = selectedProductType.prices.find( + (price) => price.name === "yearly", + )?.amount; + const yearlySavingsPercentage = + monthlyAmount && yearlyAmount + ? Math.round( + ((monthlyAmount * 12 - yearlyAmount) / (monthlyAmount * 12)) * 100, + ) + : 0; const getProductTypeButtonClass = (index: number) => cn( @@ -100,9 +118,11 @@ export default function Pricing(props: {
{selectedProductType.prices.map((p) => ( @@ -126,23 +146,23 @@ export default function Pricing(props: {
{getIcon(p.name)}
{changeCase(p.name)} @@ -151,12 +171,12 @@ export default function Pricing(props: {
@@ -178,28 +198,29 @@ export default function Pricing(props: { Community Edition
)} - {p.trial && ( + {(p.trial || + (isPopular(p.name) && yearlySavingsPercentage > 0)) && (
- {isPopular(p.name) && ( + {isPopular(p.name) && yearlySavingsPercentage > 0 && ( <> - Save 17% + Save {yearlySavingsPercentage}% -
+ {p.trial &&
} )} - with a {p.trial} days trial + {p.trial ? `with a ${p.trial} days trial` : null}
)} {p.name.toLowerCase() === "lifetime" && (
@@ -220,7 +241,11 @@ export default function Pricing(props: { onClick={(e) => { if (props.onClick && p.priceId) { e.preventDefault(); - props.onClick(p.priceId); + props.onClick( + p.priceId, + selectedProductType.type, + p.name, + ); } }} > @@ -228,7 +253,7 @@ export default function Pricing(props: { variant={isPopular(p.name) ? "default" : "outline"} className={cn( "w-full", - !isThreeColumn && "text-sm", + !isLargeCardLayout && "text-sm", isPopular(p.name) && "bg-linear-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary", )} diff --git a/apps/website/app/lib/config.server.ts b/apps/website/app/lib/config.server.ts index ec1db2e4bfe..873cf7b7d06 100644 --- a/apps/website/app/lib/config.server.ts +++ b/apps/website/app/lib/config.server.ts @@ -7,8 +7,10 @@ import { GraphQLClient } from "graphql-request"; import { createCookie } from "react-router"; import { z } from "zod"; import * as schema from "~/drizzle/schema.server"; -import { PlanTypes, ProductTypes } from "~/drizzle/schema.server"; -import { PRICING_METADATA } from "./pricing-config"; +import { + getActivePaymentCatalog, + getPaymentEnvironment, +} from "./payment-catalog"; // The number of days after a subscription expires that we allow access export const GRACE_PERIOD = 7; @@ -21,9 +23,7 @@ const serverVariablesSchema = z.object({ DATABASE_URL: z.string(), RYOT_BASE_URL: z.string(), UNKEY_ROOT_KEY: z.string(), - PADDLE_PRICE_IDS: z.string(), SERVER_SMTP_USER: z.string(), - POLAR_PRODUCT_IDS: z.string(), SERVER_SMTP_SERVER: z.string(), TURNSTILE_SITE_KEY: z.string(), POLAR_ACCESS_TOKEN: z.string(), @@ -53,58 +53,19 @@ export const getOauthCallbackUrl = memoize( () => `${getServerVariables().FRONTEND_URL}/callback`, ); -const paddlePricesEnvSchema = z.array( - z.object({ - type: z.enum(ProductTypes.enum), - prices: z.array( - z.object({ - name: z.enum(PlanTypes.enum), - priceId: z.string().optional(), - }), - ), - }), -); - export const getPrices = memoize(() => { - const envPrices = paddlePricesEnvSchema.parse( - JSON.parse(getServerVariables().PADDLE_PRICE_IDS), + const { PADDLE_SANDBOX } = getServerVariables(); + return getActivePaymentCatalog( + "paddle", + getPaymentEnvironment(PADDLE_SANDBOX), ); - - return envPrices.map((product) => ({ - ...product, - prices: product.prices.map((price) => ({ - ...price, - ...PRICING_METADATA[product.type][price.name], - })), - })); }); export type TPrices = ReturnType; -const polarProductsEnvSchema = z.array( - z.object({ - type: z.enum(ProductTypes.enum), - prices: z.array( - z.object({ - name: z.enum(PlanTypes.enum), - productId: z.string().optional(), - }), - ), - }), -); - export const getPolarProducts = memoize(() => { - const productIds = getServerVariables().POLAR_PRODUCT_IDS; - - const envProducts = polarProductsEnvSchema.parse(JSON.parse(productIds)); - - return envProducts.map((product) => ({ - ...product, - prices: product.prices.map((price) => ({ - ...price, - ...PRICING_METADATA[product.type][price.name], - })), - })); + const { POLAR_SANDBOX } = getServerVariables(); + return getActivePaymentCatalog("polar", getPaymentEnvironment(POLAR_SANDBOX)); }); export const getPolarAbPercent = memoize(() => { diff --git a/apps/website/app/lib/payment-catalog.ts b/apps/website/app/lib/payment-catalog.ts new file mode 100644 index 00000000000..b695e32f66e --- /dev/null +++ b/apps/website/app/lib/payment-catalog.ts @@ -0,0 +1,381 @@ +import type { TPlanTypes, TProductTypes } from "~/drizzle/schema.server"; + +export type PricingMetadata = { + trial?: number; + amount?: number; + linkToGithub?: boolean; +}; + +export type PaymentPrice = PricingMetadata & { + name: TPlanTypes; + priceId?: string; + productId?: string; +}; + +export type PaymentProduct = { + type: TProductTypes; + prices: PaymentPrice[]; +}; + +export type PaymentProvider = "paddle" | "polar"; +export type PaymentCatalogStatus = "active" | "legacy"; +export type PaymentEnvironment = "sandbox" | "production"; + +type PaymentCatalog = Record< + PaymentProvider, + Record> +>; + +const paddlePrice = ( + name: TPlanTypes, + priceId: string, + metadata: PricingMetadata = {}, +): PaymentPrice => ({ name, priceId, ...metadata }); + +const polarPrice = ( + name: TPlanTypes, + productId: string, + priceId: string, + metadata: PricingMetadata = {}, +): PaymentPrice => ({ name, productId, priceId, ...metadata }); + +export const PAYMENT_CATALOG: PaymentCatalog = { + paddle: { + sandbox: { + active: [ + { + type: "cloud", + prices: [ + paddlePrice("monthly", "pri_01kzgt4b5zv0tg75f2e5ss8nrk", { + amount: 6, + trial: 7, + }), + paddlePrice("yearly", "pri_01kzgt5r9rwatck1yrk58v79fg", { + amount: 50, + trial: 14, + }), + ], + }, + { + type: "self_hosted", + prices: [ + { name: "free", linkToGithub: true }, + paddlePrice("monthly", "pri_01kzgte98z6tjatyjxkfkdb9c9", { + amount: 4, + }), + paddlePrice("yearly", "pri_01kzgtctzch547svd5cnk0p8b6", { + amount: 35, + }), + paddlePrice("lifetime", "pri_01kzgt9sztgq4jrd6sc408mbse", { + amount: 120, + }), + ], + }, + ], + legacy: [ + { + type: "cloud", + prices: [ + paddlePrice("monthly", "pri_01j3jpqer93vdzwzdw6a3frefy", { + amount: 3, + trial: 7, + }), + paddlePrice("yearly", "pri_01j3jppmjzaeb4wxraeptzqk3q", { + amount: 30, + trial: 14, + }), + paddlePrice("lifetime", "pri_01j3jpnpbkfdsbn5e7f38vs66b", { + amount: 90, + }), + ], + }, + { + type: "self_hosted", + prices: [ + paddlePrice("monthly", "pri_01j237s5y1hz6061fayt8z504d", { + amount: 2, + }), + paddlePrice("yearly", "pri_01j237tn2knfdpxc9c6tmhf08f", { + amount: 20, + }), + paddlePrice("lifetime", "pri_01j237vrsqzxr5g0ctwr226tr6", { + amount: 60, + }), + ], + }, + ], + }, + production: { + active: [ + { + type: "cloud", + prices: [ + paddlePrice("monthly", "pri_01kzgtkkpsqy9dz1pv3yw18rgm", { + amount: 6, + trial: 7, + }), + paddlePrice("yearly", "pri_01kzgtmrjyb0twwd3hwv92p97y", { + amount: 50, + trial: 14, + }), + ], + }, + { + type: "self_hosted", + prices: [ + { name: "free", linkToGithub: true }, + paddlePrice("monthly", "pri_01kzgtr1ct5nxzhmdery5xhgc7", { + amount: 4, + }), + paddlePrice("yearly", "pri_01kzgtq7g4qexnaz041w0fdsqz", { + amount: 35, + }), + paddlePrice("lifetime", "pri_01kzgtp9hxx594bfdfnzg1ds3j", { + amount: 120, + }), + ], + }, + ], + legacy: [ + { + type: "cloud", + prices: [ + paddlePrice("monthly", "pri_01j3jhddt6kejw8b03qb0480n6", { + amount: 3, + trial: 7, + }), + paddlePrice("yearly", "pri_01j3jhee8h0z6b1r1y7k7xqac8", { + amount: 30, + trial: 14, + }), + paddlePrice("lifetime", "pri_01j3jhfa4g6ctw3610hj7accjc", { + amount: 90, + }), + ], + }, + { + type: "self_hosted", + prices: [ + paddlePrice("monthly", "pri_01j0sxqx6b25vywf1xvm808gv3", { + amount: 2, + }), + paddlePrice("yearly", "pri_01j0sxsgqcapxkfdbh3g8a6973", { + amount: 20, + }), + paddlePrice("lifetime", "pri_01j0sxtqjt50ckf17jcxex8wft", { + amount: 60, + }), + ], + }, + ], + }, + }, + polar: { + sandbox: { + active: [ + { + type: "cloud", + prices: [ + polarPrice( + "monthly", + "6d7234b3-668d-44ba-97ac-c6a5e7e2e42c", + "bea67a18-4d2d-41de-8007-477625340933", + { amount: 6, trial: 7 }, + ), + polarPrice( + "yearly", + "6e9786a9-0a15-4aeb-b226-d97daf485e8c", + "dfef5e6d-4730-48a0-a0b1-fc78e5530e98", + { amount: 50, trial: 14 }, + ), + ], + }, + { + type: "self_hosted", + prices: [ + { name: "free", linkToGithub: true }, + polarPrice( + "monthly", + "d159c4bb-7de0-46f4-9d80-23f0b81cc599", + "519d6a36-f140-4e42-b1a2-b22a4613faba", + { amount: 4 }, + ), + polarPrice( + "yearly", + "d24ac9e9-1eb3-4195-b5cd-fc67efd0e399", + "75cc7231-07f6-444c-ba54-682bac22b6c7", + { amount: 35 }, + ), + polarPrice( + "lifetime", + "40e76118-4537-40c1-bb07-709d62122794", + "6f55694e-15c5-4368-ae91-7a303c8f8fd3", + { amount: 120 }, + ), + ], + }, + ], + legacy: [ + { + type: "cloud", + prices: [ + polarPrice( + "monthly", + "f1075182-46d5-4936-96ab-7181d788ac4a", + "80045896-68b9-4af5-b358-9f06ee822152", + { amount: 3, trial: 7 }, + ), + polarPrice( + "yearly", + "b0025c53-ffdb-4fec-a47a-c0266c0c13d6", + "077c2e33-98ab-40e5-9842-9fa8410ea42b", + { amount: 30, trial: 14 }, + ), + polarPrice( + "lifetime", + "ee44402b-d348-4bab-9a4f-ed567c61a4ad", + "e929cdfe-e345-405c-ad96-451b6c160f60", + { amount: 90 }, + ), + ], + }, + { + type: "self_hosted", + prices: [ + polarPrice( + "monthly", + "c7c6b665-186b-4945-8040-da6a1547635e", + "91eceefb-ddec-4a63-a5af-949ac65dd14d", + { amount: 2 }, + ), + polarPrice( + "yearly", + "c683473b-a0b5-4c49-93f8-fd4b9ced953c", + "bd2b7c30-aacb-4eb1-b05f-1551731eda28", + { amount: 20 }, + ), + polarPrice( + "lifetime", + "fe0b8060-4cb7-48c9-a1ff-f3cc261930f9", + "e6ac7056-8012-46c7-b60f-830012964a16", + { amount: 60 }, + ), + ], + }, + ], + }, + production: { + active: [ + { + type: "cloud", + prices: [ + polarPrice( + "monthly", + "a658a978-826e-4ee5-92ef-7eab11db78dc", + "49a60c5a-ca28-4147-bc82-dcef9bfba4b3", + { amount: 6, trial: 7 }, + ), + polarPrice( + "yearly", + "48905e2a-85e6-45c1-9000-e19102de77b9", + "48ce231d-df76-4181-a841-2dd5a345058a", + { amount: 50, trial: 14 }, + ), + ], + }, + { + type: "self_hosted", + prices: [ + { name: "free", linkToGithub: true }, + polarPrice( + "monthly", + "e5e4926a-92e4-4441-8db3-49484b2181a3", + "cb95b611-460c-438a-bf78-13703b95e68c", + { amount: 4 }, + ), + polarPrice( + "yearly", + "a7561d3d-3b10-48ac-a54f-78495e773a0f", + "6dc7a297-98c5-4756-9b21-ff0b5f1ca7d7", + { amount: 35 }, + ), + polarPrice( + "lifetime", + "22ad1748-dcbf-4a55-8131-436af0f66918", + "1a08547f-dec5-4d5c-a7b6-7f754fa27bb2", + { amount: 120 }, + ), + ], + }, + ], + legacy: [ + { + type: "cloud", + prices: [ + polarPrice( + "monthly", + "b563e6e4-cca7-4136-b06c-f0eac3d41f8f", + "f9a6b1b9-4e6c-495a-86e1-572d94d65c89", + { amount: 3, trial: 7 }, + ), + polarPrice( + "yearly", + "b4441f58-db99-45bf-ae72-8d0d73d92dea", + "5f57aa5b-7c59-4fff-9444-b667f12b7796", + { amount: 30, trial: 14 }, + ), + polarPrice( + "lifetime", + "357395ee-2ba0-46a5-bcb2-59a583587030", + "18c16249-be88-4aa7-9014-f192161bc4de", + { amount: 90 }, + ), + ], + }, + { + type: "self_hosted", + prices: [ + polarPrice( + "monthly", + "b72d6d4b-7fb5-4fa1-b3ee-d9088485a9bf", + "72be43fd-e30a-4754-9d5a-b227f0e587ad", + { amount: 2 }, + ), + polarPrice( + "yearly", + "d9c7e138-0423-4bfb-984a-e84608a707fa", + "5b0f56f2-69db-4864-a5e3-8f0b2c146a92", + { amount: 20 }, + ), + polarPrice( + "lifetime", + "67c346a3-b341-40d5-a8e9-28eda61db1fe", + "6911a58a-7848-4913-8546-9afffe8cb1ff", + { amount: 60 }, + ), + ], + }, + ], + }, + }, +}; + +export const getPaymentEnvironment = ( + isSandbox: boolean | undefined, +): PaymentEnvironment => (isSandbox ? "sandbox" : "production"); + +export const getPaymentCatalog = ( + provider: PaymentProvider, + environment: PaymentEnvironment, + status: PaymentCatalogStatus = "active", +) => PAYMENT_CATALOG[provider][environment][status]; + +export const getActivePaymentCatalog = ( + provider: PaymentProvider, + environment: PaymentEnvironment, +) => getPaymentCatalog(provider, environment, "active"); + +export const getLegacyPaymentCatalog = ( + provider: PaymentProvider, + environment: PaymentEnvironment, +) => getPaymentCatalog(provider, environment, "legacy"); diff --git a/apps/website/app/lib/pricing-config.ts b/apps/website/app/lib/pricing-config.ts deleted file mode 100644 index 55c97103b4b..00000000000 --- a/apps/website/app/lib/pricing-config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { TPlanTypes, TProductTypes } from "~/drizzle/schema.server"; - -export type PricingMetadata = { - trial?: number; - amount?: number; - linkToGithub?: boolean; -}; - -export const PRICING_METADATA: Record< - TProductTypes, - Record -> = { - cloud: { - free: {}, - monthly: { amount: 3, trial: 7 }, - yearly: { amount: 30, trial: 14 }, - lifetime: { amount: 90 }, - }, - self_hosted: { - free: { linkToGithub: true }, - monthly: { amount: 2 }, - yearly: { amount: 20 }, - lifetime: { amount: 60 }, - }, -}; diff --git a/apps/website/app/lib/provisioning.server.ts b/apps/website/app/lib/provisioning.server.ts index 6147b8aa1b4..e0d6081acf6 100644 --- a/apps/website/app/lib/provisioning.server.ts +++ b/apps/website/app/lib/provisioning.server.ts @@ -11,6 +11,7 @@ import { and, eq, type InferSelectModel, isNull } from "drizzle-orm"; import { customerPurchases, customers, + type TPaymentProviders, type TPlanTypes, type TProductTypes, } from "~/drizzle/schema.server"; @@ -22,6 +23,7 @@ import { getUnkeyClient, } from "./config.server"; import { + backfillActivePurchaseProviderIdentity, calculateRenewalDate, createUnkeyKey, sendEmail, @@ -29,6 +31,12 @@ import { type Customer = InferSelectModel; +export type PaymentProviderIdentity = { + providerPriceId?: string; + providerProductId?: string; + paymentProvider: TPaymentProviders; +}; + type CloudAuthDetails = Extract< NonNullable, { __typename: "cloud" } @@ -159,7 +167,8 @@ export async function provisionNewPurchase( customer: Customer, planType: TPlanTypes, productType: TProductTypes, - paymentProviderCustomerId?: string, + paymentProviderCustomerId: string, + providerIdentity: PaymentProviderIdentity, ) { const { ryotUserId, unkeyKeyId, details } = productType === "cloud" @@ -178,12 +187,15 @@ export async function provisionNewPurchase( subject: PurchaseCompleteEmail.subject, }); - await getDb().insert(customerPurchases).values({ - planType, - productType, - customerId: customer.id, - renewOn: renewalDate?.toDate(), - }); + await getDb() + .insert(customerPurchases) + .values({ + planType, + productType, + customerId: customer.id, + ...providerIdentity, + renewOn: renewalDate?.toDate(), + }); const updateData: { ryotUserId?: string | null; @@ -221,6 +233,7 @@ export async function provisionRenewal( planType: TPlanTypes, productType: TProductTypes, activePurchase: InferSelectModel, + providerIdentity: PaymentProviderIdentity, ) { const renewalDate = calculateRenewalDate(planType); await getDb() @@ -228,6 +241,7 @@ export async function provisionRenewal( .set({ planType, productType, + ...providerIdentity, updatedOn: new Date(), renewOn: renewalDate?.toDate(), }) @@ -295,13 +309,16 @@ export async function revokePurchase(customer: Customer) { } } -export async function getActivePurchase(customerId: string) { - return await getDb().query.customerPurchases.findFirst({ +export async function getActivePurchase(customer: Customer) { + const activePurchase = await getDb().query.customerPurchases.findFirst({ where: and( - eq(customerPurchases.customerId, customerId), + eq(customerPurchases.customerId, customer.id), isNull(customerPurchases.cancelledOn), ), }); + if (!activePurchase) return null; + + return backfillActivePurchaseProviderIdentity(customer, activePurchase); } export async function handlePurchaseOrRenewal( @@ -309,20 +326,23 @@ export async function handlePurchaseOrRenewal( planType: TPlanTypes, productType: TProductTypes, paymentProviderCustomerId: string, + providerIdentity: PaymentProviderIdentity, ) { - const activePurchase = await getActivePurchase(customer.id); + const activePurchase = await getActivePurchase(customer); if (!activePurchase) { console.log("Customer purchased plan:", { planType, productType, paymentProviderCustomerId, + providerIdentity, }); await provisionNewPurchase( customer, planType, productType, paymentProviderCustomerId, + providerIdentity, ); } else { console.log("Customer renewed plan:", { @@ -330,6 +350,12 @@ export async function handlePurchaseOrRenewal( productType, paymentProviderCustomerId, }); - await provisionRenewal(customer, planType, productType, activePurchase); + await provisionRenewal( + customer, + planType, + productType, + activePurchase, + providerIdentity, + ); } } diff --git a/apps/website/app/lib/utilities.server.ts b/apps/website/app/lib/utilities.server.ts index d4156ea03ab..c2c0ea513cb 100644 --- a/apps/website/app/lib/utilities.server.ts +++ b/apps/website/app/lib/utilities.server.ts @@ -13,12 +13,16 @@ import type { TPlanTypes } from "~/drizzle/schema.server"; import * as schema from "~/drizzle/schema.server"; import { getDb, - getPrices, getServerVariables, getUnkeyClient, IS_DEVELOPMENT_ENV, websiteAuthCookie, } from "./config.server"; +import { + getActivePaymentCatalog, + getLegacyPaymentCatalog, + getPaymentEnvironment, +} from "./payment-catalog"; export const getClientIp = (request: Request): string | undefined => { const cfConnectingIp = request.headers.get("cf-connecting-ip"); @@ -34,13 +38,92 @@ export const getClientIp = (request: Request): string | undefined => { }; export const getProductAndPlanTypeByPriceId = (priceId: string) => { - for (const product of getPrices()) - for (const price of product.prices) - if (price.priceId === priceId) - return { productType: product.type, planType: price.name }; + const { PADDLE_SANDBOX } = getServerVariables(); + const environment = getPaymentEnvironment(PADDLE_SANDBOX); + const catalogs = [ + getActivePaymentCatalog("paddle", environment), + getLegacyPaymentCatalog("paddle", environment), + ]; + + for (const catalog of catalogs) + for (const product of catalog) + for (const price of product.prices) + if (price.priceId === priceId) + return { productType: product.type, planType: price.name }; + throw new Error("Price ID not found"); }; +export const getProductAndPlanTypeByPolarIds = ( + productId: string, + priceId?: string | null, +) => { + const { POLAR_SANDBOX } = getServerVariables(); + const environment = getPaymentEnvironment(POLAR_SANDBOX); + const catalogs = [ + getActivePaymentCatalog("polar", environment), + getLegacyPaymentCatalog("polar", environment), + ]; + + for (const catalog of catalogs) + for (const product of catalog) + for (const price of product.prices) + if ( + price.productId === productId && + (priceId == null || price.priceId === priceId) + ) + return { productType: product.type, planType: price.name }; + + return null; +}; + +export const backfillActivePurchaseProviderIdentity = async ( + customer: typeof schema.customers.$inferSelect, + activePurchase: typeof schema.customerPurchases.$inferSelect, +) => { + if (activePurchase.cancelledOn) return activePurchase; + + const hasProviderIdentity = + (activePurchase.paymentProvider === "paddle" && + !!activePurchase.providerPriceId) || + (activePurchase.paymentProvider === "polar" && + !!activePurchase.providerProductId && + !!activePurchase.providerPriceId); + if (hasProviderIdentity) return activePurchase; + + const serverVariables = getServerVariables(); + const environment = getPaymentEnvironment( + customer.paymentProvider === "paddle" + ? serverVariables.PADDLE_SANDBOX + : serverVariables.POLAR_SANDBOX, + ); + const product = getLegacyPaymentCatalog( + customer.paymentProvider, + environment, + ).find((entry) => entry.type === activePurchase.productType); + const price = product?.prices.find( + (entry) => entry.name === activePurchase.planType, + ); + if (!price?.priceId) return activePurchase; + if (customer.paymentProvider === "polar" && !price.productId) + return activePurchase; + + const providerIdentity = { + paymentProvider: customer.paymentProvider, + providerPriceId: price.priceId, + ...(customer.paymentProvider === "polar" + ? { providerProductId: price.productId } + : {}), + }; + const [updatedPurchase] = await getDb() + .update(schema.customerPurchases) + .set(providerIdentity) + .where(eq(schema.customerPurchases.id, activePurchase.id)) + .returning(); + + return updatedPurchase ?? activePurchase; +}; + export const oauthConfig = async () => { const serverVariables = getServerVariables(); const config = await openidClient.discovery( @@ -135,20 +218,25 @@ export const getCustomerWithActivePurchase = async (request: Request) => { isNull(schema.customerPurchases.cancelledOn), ), }); + const activePurchaseWithProviderIdentity = activePurchase + ? await backfillActivePurchaseProviderIdentity(customer, activePurchase) + : null; return { ...customer, - activePurchase, - planType: activePurchase?.planType || null, - hasCancelled: !!activePurchase?.cancelledOn, - productType: activePurchase?.productType || null, + activePurchase: activePurchaseWithProviderIdentity, + planType: activePurchaseWithProviderIdentity?.planType || null, + hasCancelled: !!activePurchaseWithProviderIdentity?.cancelledOn, + productType: activePurchaseWithProviderIdentity?.productType || null, ryotUserId: - activePurchase?.productType === "cloud" ? customer.ryotUserId : null, - renewOn: activePurchase?.renewOn - ? formatDateToNaiveDate(activePurchase.renewOn) + activePurchaseWithProviderIdentity?.productType === "cloud" + ? customer.ryotUserId + : null, + renewOn: activePurchaseWithProviderIdentity?.renewOn + ? formatDateToNaiveDate(activePurchaseWithProviderIdentity.renewOn) : null, unkeyKeyId: - activePurchase?.productType === "self_hosted" + activePurchaseWithProviderIdentity?.productType === "self_hosted" ? customer.unkeyKeyId : null, }; diff --git a/apps/website/app/routes/me.tsx b/apps/website/app/routes/me.tsx index 79ce1446617..2d5c63aa28b 100644 --- a/apps/website/app/routes/me.tsx +++ b/apps/website/app/routes/me.tsx @@ -5,7 +5,6 @@ import dayjs from "dayjs"; import { eq } from "drizzle-orm"; import { useEffect, useState } from "react"; import { data, Form, redirect, useFetcher, useLoaderData } from "react-router"; -import { toast } from "sonner"; import { match } from "ts-pattern"; import { withQuery } from "ufo"; import { @@ -368,34 +367,15 @@ export default function Index() { { + onClick={(priceId, productType, planType) => { if (loaderData.paymentProvider === "polar") { - let planType = ""; - let productType = ""; - const prices = loaderData.prices; - - for (const product of prices) { - const matchingPrice = product.prices.find( - (p) => p.priceId === priceId, - ); - if (matchingPrice) { - productType = product.type; - planType = matchingPrice.name; - break; - } - } - - if (productType && planType) { - const formData = new FormData(); - formData.append("planType", planType); - formData.append("productType", productType); - fetcher.submit(formData, { - method: "POST", - action: withQuery(".", { intent: "checkoutPolar" }), - }); - } else { - toast.error("Unable to determine product for checkout."); - } + const formData = new FormData(); + formData.append("planType", planType); + formData.append("productType", productType); + fetcher.submit(formData, { + method: "POST", + action: withQuery(".", { intent: "checkoutPolar" }), + }); return; } diff --git a/apps/website/app/routes/paddle-webhook.tsx b/apps/website/app/routes/paddle-webhook.tsx index d7fc5a73888..dd710061fcc 100644 --- a/apps/website/app/routes/paddle-webhook.tsx +++ b/apps/website/app/routes/paddle-webhook.tsx @@ -70,6 +70,10 @@ async function handleTransactionCompleted( planType, productType, paddleCustomerId, + { + providerPriceId: priceId, + paymentProvider: "paddle", + }, ); revokePurchaseInProgress(customer.id); diff --git a/apps/website/app/routes/polar-webhook.tsx b/apps/website/app/routes/polar-webhook.tsx index 7782ea39d93..9898e67c2ca 100644 --- a/apps/website/app/routes/polar-webhook.tsx +++ b/apps/website/app/routes/polar-webhook.tsx @@ -1,12 +1,11 @@ import { validateEvent } from "@polar-sh/sdk/webhooks"; import { data } from "react-router"; import { match } from "ts-pattern"; -import type { TPlanTypes, TProductTypes } from "~/drizzle/schema.server"; import { revokeCancellation, revokePurchaseInProgress, } from "~/lib/caches.server"; -import { getPolarProducts, getPolarWebhookSecret } from "~/lib/config.server"; +import { getPolarWebhookSecret } from "~/lib/config.server"; import { findCustomerById, findCustomerByPolarId, @@ -16,6 +15,7 @@ import { handlePurchaseOrRenewal, revokePurchase, } from "~/lib/provisioning.server"; +import { getProductAndPlanTypeByPolarIds } from "~/lib/utilities.server"; import type { Route } from "./+types/polar-webhook"; async function findCustomer( @@ -30,20 +30,6 @@ async function findCustomer( ); } -function findPlanAndProductType( - productId: string, -): { planType: TPlanTypes; productType: TProductTypes } | null { - const products = getPolarProducts(); - - for (const product of products) { - const matchingPrice = product.prices.find((p) => p.productId === productId); - if (matchingPrice) - return { productType: product.type, planType: matchingPrice.name }; - } - - return null; -} - async function handleOrderPaid( event: ReturnType, ): Promise<{ error?: string; message?: string }> { @@ -67,7 +53,8 @@ async function handleOrderPaid( const productId = order.productId; if (!productId) return { error: "Product ID not found in order" }; - const planAndProduct = findPlanAndProductType(productId); + const priceId = order.items[0]?.productPriceId; + const planAndProduct = getProductAndPlanTypeByPolarIds(productId, priceId); if (!planAndProduct) return { error: `No matching product found for product ID: ${productId}` }; @@ -78,6 +65,11 @@ async function handleOrderPaid( planType, productType, polarCustomerId, + { + paymentProvider: "polar", + providerProductId: productId, + providerPriceId: priceId ?? undefined, + }, ); revokePurchaseInProgress(customer.id); From 11fa303c0cda3bf13a2c3a9016b677a429d63728 Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sat, 8 Aug 2026 23:14:06 +0530 Subject: [PATCH 02/11] feat(website): publish pricing promise for grandfathered customers Add a standalone undated pricing promise page that explains how existing subscription and lifetime rates are protected as new pricing is introduced. Link the promise from the pricing section and shared footer, and prerender the route so the commitment is directly accessible. --- apps/website/app/lib/components/Pricing.tsx | 8 ++++ apps/website/app/root.tsx | 10 ++++- apps/website/app/routes/pricing-promise.tsx | 50 +++++++++++++++++++++ apps/website/react-router.config.ts | 4 +- 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 apps/website/app/routes/pricing-promise.tsx diff --git a/apps/website/app/lib/components/Pricing.tsx b/apps/website/app/lib/components/Pricing.tsx index 156b5ae9387..f9cf457555a 100644 --- a/apps/website/app/lib/components/Pricing.tsx +++ b/apps/website/app/lib/components/Pricing.tsx @@ -90,6 +90,14 @@ export default function Pricing(props: { . Choose the one that best fits your needs.

+
+ + Read our pricing promise + +
diff --git a/apps/website/app/root.tsx b/apps/website/app/root.tsx index 14aa0472be3..9cd314bf69b 100644 --- a/apps/website/app/root.tsx +++ b/apps/website/app/root.tsx @@ -199,7 +199,7 @@ export default function App() { Ryot
-
+
Support + + Pricing Promise + Terms diff --git a/apps/website/app/routes/pricing-promise.tsx b/apps/website/app/routes/pricing-promise.tsx new file mode 100644 index 00000000000..521ee3ce8a1 --- /dev/null +++ b/apps/website/app/routes/pricing-promise.tsx @@ -0,0 +1,50 @@ +import { ShieldCheck } from "lucide-react"; +import { SectionHeader } from "~/lib/components/SectionHeader"; +import { Card } from "~/lib/components/ui/card"; + +export const meta = () => { + return [{ title: "Pricing Promise | Ryot" }]; +}; + +export default function Page() { + return ( +
+
+ + + +
+

+ Subscribe today and you keep the rate you signed up at, for as + long as your subscription stays active. That applies to every + future price change. We don't move existing customers onto new + pricing. +

+

+ Buy a lifetime license and it's yours permanently. No renewal, no + expiry, no reconsidering. +

+

+ One boundary, stated now so it isn't a surprise later: if your + subscription lapses and you resubscribe, you return at the current + price. Your old rate goes with the old subscription. If a payment + fails, email us - we'll fix it and keep your rate. That's not a + loophole we're looking to use. +

+

+ We'd rather charge new customers more than break a deal with the + people who backed us first. +

+
+
+
+
+ ); +} diff --git a/apps/website/react-router.config.ts b/apps/website/react-router.config.ts index f5a61976b8b..1d27c950475 100644 --- a/apps/website/react-router.config.ts +++ b/apps/website/react-router.config.ts @@ -1,3 +1,5 @@ import type { Config } from "@react-router/dev/config"; -export default { prerender: ["/", "/features", "/terms"] } satisfies Config; +export default { + prerender: ["/", "/features", "/terms", "/pricing-promise"], +} satisfies Config; From 99dbf75ee350af76b04171ffa984f4728a681cd0 Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sat, 8 Aug 2026 23:40:50 +0530 Subject: [PATCH 03/11] feat(website): announce the price increase and the grandfathering promise The versioned payment catalog on this branch raises cloud to $6/$50 and self hosted to $4/$35/$120, while keeping the legacy price IDs alive so existing subscribers keep renewing at the rate they signed up at. That behaviour is invisible to customers unless we say it out loud, and the /pricing-promise page is deliberately short and evergreen - it states the policy but carries no date, no numbers, and no reasoning. This adds the durable artifact for the change: a blog post that can be linked for years. It names the effective date (9 August 2026), lists the old and new amounts, explains why the original prices were set too low to sustain the project, and spells out the four rules that decide who keeps their rate - subscription start date before the cutoff, lifetime licenses untouched, cancel-and-return at the current price, new customers covered from their first payment. The post is written so it stays accurate after the change ships, so it avoids "subscribe today" framing and anchors every statement to the fixed date instead. Prices are listed as lists rather than a table because the MDX pipeline does not include remark-gfm and a table would render as literal text. Billing problems are routed to the home page contact form rather than a raw email address. No code or route changes are needed - the blog registry globs the content directory, so the file alone publishes at /blog/ryot-pricing-is-changing. Distribution to existing customers is handled separately. --- .../blog/ryot-pricing-is-changing/index.mdx | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 apps/website/app/content/blog/ryot-pricing-is-changing/index.mdx diff --git a/apps/website/app/content/blog/ryot-pricing-is-changing/index.mdx b/apps/website/app/content/blog/ryot-pricing-is-changing/index.mdx new file mode 100644 index 00000000000..d7e8d774254 --- /dev/null +++ b/apps/website/app/content/blog/ryot-pricing-is-changing/index.mdx @@ -0,0 +1,121 @@ +--- +title: Ryot's prices are going up. Yours isn't. +description: From 9 August 2026, Ryot Pro costs more. If you already pay for it, your price does not change - not now, not at any future increase. +publishedAt: 2026-08-09 +properties: + labels: + - ryot + - pricing + - self hosting +--- + +export const tableOfContents = [ + { id: "the-new-prices", label: "The new prices", depth: 2 }, + { id: "why-i-am-doing-this", label: "Why I am doing this", depth: 2 }, + { + id: "what-this-means-if-you-already-pay", + label: "What this means if you already pay", + depth: 2, + }, + { id: "questions", label: "Questions", depth: 2 }, +]; + +From 9 August 2026, Ryot Pro costs more than it used to. Cloud goes from $3 to $6 a +month. Self hosted goes from $2 to $4. + +If you are already paying for Ryot, none of that applies to you. You keep the price you +signed up at, for as long as your subscription stays active. There is nothing you need +to do, nothing to migrate, and no action required on your side. + +## The new prices + +Ryot Pro is sold in two product types, and both are changing. All amounts are in USD and +exclusive of any taxes that apply where you live. + +**Cloud**, hosted by me at [ryot.io](https://ryot.io): + +- Monthly: $3 becomes $6 +- Yearly: $30 becomes $50 + +**Self hosted**, where you run Ryot on your own server: + +- Monthly: $2 becomes $4 +- Yearly: $20 becomes $35 +- Lifetime: $60 becomes $120 + +Self hosting Ryot itself is still free. The application is open source, and this change +does not move any existing free functionality behind the paid tier. Pro is a layer on +top of that, and it stays optional. + +## Why I am doing this + +The old prices were not the result of a calculation. I set them when Ryot was a side +project I maintained between other things, and I picked numbers that felt small enough +that nobody would think twice about them. $3 a month was priced to be ignored. It was +never priced to pay for anything. + +What it actually has to pay for became clearer over time. Servers and databases for the +cloud version. Bills from the external data providers Ryot queries for every movie, book, +show, and game you track. Payment processing, which takes a fixed cut per transaction and +therefore eats a genuinely silly percentage of a $3 charge. Support, which is me, most +days, before and after other work. + +The larger reason is the one I wrote about in +[why I am rewriting Ryot](/blog/why-i-am-rewriting-ryot). That rewrite is a bet - roughly +2000 commits of it - that Ryot is worth building properly rather than maintaining +indefinitely in whatever hours are left over. I want to work on it full time. I want the +bug you report to be fixed that week, not whenever I next surface. I want to keep the +cloud version running for years without quietly hoping the hosting bill stays small. + +At $3 a month, that arithmetic never closes. At $6 it starts to. That is the whole +reason, and I would rather say it plainly than dress it up as an improvement to your +plan. + +## What this means if you already pay + +Everything below is policy, not a promotion. It is the same commitment described on the +[pricing promise](/pricing-promise) page, and it applies to this price change and to +every one after it. + +- **If your subscription started before 9 August 2026**, you keep that exact rate for as + long as the subscription stays active. Your renewals continue to be charged at the + price you originally signed up at. I do not move existing customers onto new pricing. +- **If you bought a lifetime license**, it is yours permanently. No renewal, no expiry, + nothing to re-purchase. +- **If you cancel and later come back**, you return at whatever the price is then. The + old rate belongs to the old subscription and does not survive it. +- **If you are new**, the prices above are what you pay, and the same promise applies to + you from your first payment onwards. + +I would rather charge new customers more than break the deal I made with the people who +paid for this first. + +## Questions + +**Will my renewal be charged at the new price?** + +No. An active subscription keeps renewing at the rate it started on. + +**Do I need to do anything to keep my price?** + +No. Nothing changes on your account, and there is no form to fill or plan to re-select. +Keeping your subscription active is the entire requirement. + +**What if I cancel and resubscribe later?** + +You come back at the price current on that day. If you are unsure about cancelling, it +is worth knowing that this is the one thing that ends the old rate. + +**I bought a lifetime license. Is it affected?** + +Not at all. Lifetime means lifetime. + +**Does this change anything about self hosting Ryot for free?** + +No. The open source application stays free to self host, and nothing that is free today +moves behind Pro because of this change. + +**Something on my bill looks wrong. What do I do?** + +Send it through the [contact form](/#contact) with the address you signed up with, and I +will look at it directly. From 6d91c78bc5648dd172872042e07013acc1398f12 Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sun, 9 Aug 2026 09:04:44 +0530 Subject: [PATCH 04/11] feat(website): eagerly backfill legacy purchase identities Run the environment-aware legacy payment catalog backfill immediately after schema migrations so every existing purchase receives its provider price and product identity before renewal processing. Remove the lazy read-path backfill to avoid incomplete data and database writes during authenticated reads. --- apps/website/app/lib/migrations.server.ts | 65 ++++++++++++++++++++ apps/website/app/lib/provisioning.server.ts | 12 ++-- apps/website/app/lib/utilities.server.ts | 68 +++------------------ apps/website/app/routes/health.tsx | 4 +- 4 files changed, 79 insertions(+), 70 deletions(-) create mode 100644 apps/website/app/lib/migrations.server.ts diff --git a/apps/website/app/lib/migrations.server.ts b/apps/website/app/lib/migrations.server.ts new file mode 100644 index 00000000000..819134393a0 --- /dev/null +++ b/apps/website/app/lib/migrations.server.ts @@ -0,0 +1,65 @@ +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import * as schema from "~/drizzle/schema.server"; +import { getDb, getServerVariables } from "./config.server"; +import { + getLegacyPaymentCatalog, + getPaymentEnvironment, +} from "./payment-catalog"; + +const MIGRATIONS_FOLDER = "app/drizzle/migrations"; + +const backfillLegacyPurchaseProviderIdentity = async () => { + const db = getDb(); + const serverVariables = getServerVariables(); + const environments = { + polar: getPaymentEnvironment(serverVariables.POLAR_SANDBOX), + paddle: getPaymentEnvironment(serverVariables.PADDLE_SANDBOX), + }; + + let backfilledPurchases = 0; + for (const paymentProvider of schema.paymentProviders.enumValues) { + const providerCustomerIds = db + .select({ id: schema.customers.id }) + .from(schema.customers) + .where(eq(schema.customers.paymentProvider, paymentProvider)); + const catalog = getLegacyPaymentCatalog( + paymentProvider, + environments[paymentProvider], + ); + + for (const product of catalog) + for (const price of product.prices) { + if (!price.priceId) continue; + if (paymentProvider === "polar" && !price.productId) continue; + + const backfilled = await db + .update(schema.customerPurchases) + .set({ + paymentProvider, + providerPriceId: price.priceId, + providerProductId: price.productId ?? null, + }) + .where( + and( + eq(schema.customerPurchases.planType, price.name), + eq(schema.customerPurchases.productType, product.type), + isNull(schema.customerPurchases.paymentProvider), + inArray(schema.customerPurchases.customerId, providerCustomerIds), + ), + ) + .returning({ id: schema.customerPurchases.id }); + backfilledPurchases += backfilled.length; + } + } + + if (backfilledPurchases > 0) + console.log( + `Backfilled provider identity for ${backfilledPurchases} purchases`, + ); +}; + +export const runMigrations = async () => { + await migrate(getDb(), { migrationsFolder: MIGRATIONS_FOLDER }); + await backfillLegacyPurchaseProviderIdentity(); +}; diff --git a/apps/website/app/lib/provisioning.server.ts b/apps/website/app/lib/provisioning.server.ts index e0d6081acf6..07b275b10aa 100644 --- a/apps/website/app/lib/provisioning.server.ts +++ b/apps/website/app/lib/provisioning.server.ts @@ -23,7 +23,6 @@ import { getUnkeyClient, } from "./config.server"; import { - backfillActivePurchaseProviderIdentity, calculateRenewalDate, createUnkeyKey, sendEmail, @@ -309,16 +308,13 @@ export async function revokePurchase(customer: Customer) { } } -export async function getActivePurchase(customer: Customer) { - const activePurchase = await getDb().query.customerPurchases.findFirst({ +export async function getActivePurchase(customerId: string) { + return await getDb().query.customerPurchases.findFirst({ where: and( - eq(customerPurchases.customerId, customer.id), + eq(customerPurchases.customerId, customerId), isNull(customerPurchases.cancelledOn), ), }); - if (!activePurchase) return null; - - return backfillActivePurchaseProviderIdentity(customer, activePurchase); } export async function handlePurchaseOrRenewal( @@ -328,7 +324,7 @@ export async function handlePurchaseOrRenewal( paymentProviderCustomerId: string, providerIdentity: PaymentProviderIdentity, ) { - const activePurchase = await getActivePurchase(customer); + const activePurchase = await getActivePurchase(customer.id); if (!activePurchase) { console.log("Customer purchased plan:", { diff --git a/apps/website/app/lib/utilities.server.ts b/apps/website/app/lib/utilities.server.ts index c2c0ea513cb..b89e7a07a86 100644 --- a/apps/website/app/lib/utilities.server.ts +++ b/apps/website/app/lib/utilities.server.ts @@ -77,53 +77,6 @@ export const getProductAndPlanTypeByPolarIds = ( return null; }; -export const backfillActivePurchaseProviderIdentity = async ( - customer: typeof schema.customers.$inferSelect, - activePurchase: typeof schema.customerPurchases.$inferSelect, -) => { - if (activePurchase.cancelledOn) return activePurchase; - - const hasProviderIdentity = - (activePurchase.paymentProvider === "paddle" && - !!activePurchase.providerPriceId) || - (activePurchase.paymentProvider === "polar" && - !!activePurchase.providerProductId && - !!activePurchase.providerPriceId); - if (hasProviderIdentity) return activePurchase; - - const serverVariables = getServerVariables(); - const environment = getPaymentEnvironment( - customer.paymentProvider === "paddle" - ? serverVariables.PADDLE_SANDBOX - : serverVariables.POLAR_SANDBOX, - ); - const product = getLegacyPaymentCatalog( - customer.paymentProvider, - environment, - ).find((entry) => entry.type === activePurchase.productType); - const price = product?.prices.find( - (entry) => entry.name === activePurchase.planType, - ); - if (!price?.priceId) return activePurchase; - if (customer.paymentProvider === "polar" && !price.productId) - return activePurchase; - - const providerIdentity = { - paymentProvider: customer.paymentProvider, - providerPriceId: price.priceId, - ...(customer.paymentProvider === "polar" - ? { providerProductId: price.productId } - : {}), - }; - const [updatedPurchase] = await getDb() - .update(schema.customerPurchases) - .set(providerIdentity) - .where(eq(schema.customerPurchases.id, activePurchase.id)) - .returning(); - - return updatedPurchase ?? activePurchase; -}; - export const oauthConfig = async () => { const serverVariables = getServerVariables(); const config = await openidClient.discovery( @@ -218,25 +171,20 @@ export const getCustomerWithActivePurchase = async (request: Request) => { isNull(schema.customerPurchases.cancelledOn), ), }); - const activePurchaseWithProviderIdentity = activePurchase - ? await backfillActivePurchaseProviderIdentity(customer, activePurchase) - : null; return { ...customer, - activePurchase: activePurchaseWithProviderIdentity, - planType: activePurchaseWithProviderIdentity?.planType || null, - hasCancelled: !!activePurchaseWithProviderIdentity?.cancelledOn, - productType: activePurchaseWithProviderIdentity?.productType || null, + activePurchase, + planType: activePurchase?.planType || null, + hasCancelled: !!activePurchase?.cancelledOn, + productType: activePurchase?.productType || null, ryotUserId: - activePurchaseWithProviderIdentity?.productType === "cloud" - ? customer.ryotUserId - : null, - renewOn: activePurchaseWithProviderIdentity?.renewOn - ? formatDateToNaiveDate(activePurchaseWithProviderIdentity.renewOn) + activePurchase?.productType === "cloud" ? customer.ryotUserId : null, + renewOn: activePurchase?.renewOn + ? formatDateToNaiveDate(activePurchase.renewOn) : null, unkeyKeyId: - activePurchaseWithProviderIdentity?.productType === "self_hosted" + activePurchase?.productType === "self_hosted" ? customer.unkeyKeyId : null, }; diff --git a/apps/website/app/routes/health.tsx b/apps/website/app/routes/health.tsx index c77ac007dee..180351822a1 100644 --- a/apps/website/app/routes/health.tsx +++ b/apps/website/app/routes/health.tsx @@ -1,7 +1,7 @@ import { writeFileSync } from "node:fs"; import { sql } from "drizzle-orm"; -import { migrate } from "drizzle-orm/postgres-js/migrator"; import { getDb, getServerVariables, TEMP_DIRECTORY } from "~/lib/config.server"; +import { runMigrations } from "~/lib/migrations.server"; let hasRunStartup = false; @@ -9,7 +9,7 @@ export const loader = async () => { const serverVariables = getServerVariables(); try { if (!hasRunStartup) { - await migrate(getDb(), { migrationsFolder: "app/drizzle/migrations" }); + await runMigrations(); writeFileSync( `${TEMP_DIRECTORY}/website-config.json`, JSON.stringify(serverVariables, null, 2), From 76809f46c27bfd39d2ba0e0beb39a0e043f22a2a Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sun, 9 Aug 2026 13:04:06 +0530 Subject: [PATCH 05/11] chore: add instructions to refresh the page --- apps/website/app/routes/me.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/website/app/routes/me.tsx b/apps/website/app/routes/me.tsx index 2d5c63aa28b..2f03e80748f 100644 --- a/apps/website/app/routes/me.tsx +++ b/apps/website/app/routes/me.tsx @@ -281,7 +281,7 @@ export default function Index() { role="alert" className="mx-auto mt-6 w-full max-w-md rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900" > - Cancellation in progress. This can take a minute to sync. + Cancellation in progress. This can take a minute to sync. Please refresh the page after a minute to see the updated status.
) : null} {loaderData.isPurchaseInProgress ? ( @@ -289,7 +289,7 @@ export default function Index() { role="alert" className="mx-auto mt-6 w-full max-w-md rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900" > - Purchase in progress. This can take a minute to sync. + Purchase in progress. This can take a minute to sync. Please refresh the page after a minute to see the updated status.
) : null} {!loaderData.customerDetails.hasCancelled && From 59ad763e9cde4d94b7068a6ffcef6eaa2729193b Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sun, 9 Aug 2026 13:17:27 +0530 Subject: [PATCH 06/11] feat(webhook): enhance error handling and response status for paddle webhooks --- apps/website/app/routes/paddle-webhook.tsx | 72 ++++++++++++++++------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/apps/website/app/routes/paddle-webhook.tsx b/apps/website/app/routes/paddle-webhook.tsx index dd710061fcc..07de07401ad 100644 --- a/apps/website/app/routes/paddle-webhook.tsx +++ b/apps/website/app/routes/paddle-webhook.tsx @@ -123,36 +123,68 @@ async function handleSubscriptionResumed( export const action = async ({ request }: Route.ActionArgs) => { const paddleSignature = request.headers.get("paddle-signature"); - if (!paddleSignature) return data({ error: "No paddle signature" }); + if (!paddleSignature) + return data({ error: "No paddle signature" }, { status: 401 }); const serverVariables = getServerVariables(); const paddleClient = getPaddleServerClient(); const requestBody = await request.text(); - const eventData = await paddleClient.webhooks.unmarshal( - requestBody, - serverVariables.PADDLE_WEBHOOK_SECRET_KEY, - paddleSignature, - ); - if (!eventData) return data({ error: "No event data found in request body" }); + let eventData: Awaited< + ReturnType + >; + try { + eventData = await paddleClient.webhooks.unmarshal( + requestBody, + serverVariables.PADDLE_WEBHOOK_SECRET_KEY, + paddleSignature, + ); + } catch (error) { + console.error("Paddle webhook validation failed:", error); + const isInvalidSignature = + error instanceof Error && + error.message.toLowerCase().includes("signature verification failed"); + return data( + { + error: isInvalidSignature + ? "Invalid paddle signature" + : "Invalid webhook payload", + }, + { status: isInvalidSignature ? 401 : 400 }, + ); + } + if (!eventData) + return data( + { error: "No event data found in request body" }, + { status: 400 }, + ); const { eventType, data: paddleData } = eventData; console.log("Received event:", { eventType }); let result: WebhookResponse; - - if (eventType === EventName.TransactionCompleted) - result = await handleTransactionCompleted(paddleData); - else if ( - eventType === EventName.SubscriptionCanceled || - eventType === EventName.SubscriptionPaused || - eventType === EventName.SubscriptionPastDue - ) - result = await handleSubscriptionCancelled(paddleData); - else if (eventType === EventName.SubscriptionResumed) - result = await handleSubscriptionResumed(paddleData); - else result = { message: "Webhook event not handled" }; + try { + if (eventType === EventName.TransactionCompleted) + result = await handleTransactionCompleted(paddleData); + else if ( + eventType === EventName.SubscriptionCanceled || + eventType === EventName.SubscriptionPaused || + eventType === EventName.SubscriptionPastDue + ) + result = await handleSubscriptionCancelled(paddleData); + else if (eventType === EventName.SubscriptionResumed) + result = await handleSubscriptionResumed(paddleData); + else result = { message: "Webhook event not handled" }; + } catch (error) { + console.error("Paddle webhook handling failed:", error); + return data( + { error: "Paddle webhook could not be processed" }, + { status: 503 }, + ); + } console.log("Webhook handling result:", result); - return data(result); + return data(result, { + status: result.error === "Price ID not found" ? 400 : 200, + }); }; From 491f687fbb9edaced40d8137a6f1ea5985eb4048 Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sun, 9 Aug 2026 13:19:57 +0530 Subject: [PATCH 07/11] feat(webhook): improve webhook validation and error handling for polar events --- apps/website/app/routes/polar-webhook.tsx | 41 ++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/website/app/routes/polar-webhook.tsx b/apps/website/app/routes/polar-webhook.tsx index 9898e67c2ca..5e802a59ca9 100644 --- a/apps/website/app/routes/polar-webhook.tsx +++ b/apps/website/app/routes/polar-webhook.tsx @@ -1,4 +1,7 @@ -import { validateEvent } from "@polar-sh/sdk/webhooks"; +import { + validateEvent, + WebhookVerificationError, +} from "@polar-sh/sdk/webhooks"; import { data } from "react-router"; import { match } from "ts-pattern"; import { @@ -112,18 +115,40 @@ export const action = async ({ request }: Route.ActionArgs) => { try { event = validateEvent(body, headers, webhookSecret); } catch (error) { - console.error("Webhook validation failed:", error); - return data({ error: "Invalid webhook signature" }, { status: 401 }); + console.error("Polar webhook validation failed:", error); + const isInvalidSignature = error instanceof WebhookVerificationError; + return data( + { + error: isInvalidSignature + ? "Invalid webhook signature" + : "Invalid webhook payload", + }, + { status: isInvalidSignature ? 401 : 400 }, + ); } console.log("Received Polar webhook event:", { type: event.type }); - const result = await match(event.type) - .with("order.paid", () => handleOrderPaid(event)) - .with("subscription.revoked", () => handleSubscriptionRevoked(event)) - .otherwise(() => ({ message: "Webhook event not handled" })); + let result: { error?: string; message?: string }; + try { + result = await match(event.type) + .with("order.paid", () => handleOrderPaid(event)) + .with("subscription.revoked", () => handleSubscriptionRevoked(event)) + .otherwise(() => ({ message: "Webhook event not handled" })); + } catch (error) { + console.error("Polar webhook handling failed:", error); + return data( + { error: "Polar webhook could not be processed" }, + { status: 503 }, + ); + } console.log("Webhook handling result:", result); - return data(result); + const status = result.error?.startsWith("No matching product found") + ? 503 + : result.error === "Product ID not found in order" + ? 400 + : 200; + return data(result, { status }); }; From 65c9e9e9f6c6030293f507c7ddbd7b82236ad575 Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sun, 9 Aug 2026 13:25:10 +0530 Subject: [PATCH 08/11] chore: reorder columns --- apps/website/app/drizzle/schema.server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/website/app/drizzle/schema.server.ts b/apps/website/app/drizzle/schema.server.ts index 95c27b62a86..847af3a0ae1 100644 --- a/apps/website/app/drizzle/schema.server.ts +++ b/apps/website/app/drizzle/schema.server.ts @@ -71,11 +71,11 @@ export const customerPurchases = pgTable( "customer_purchase", { planType: planTypes("plan_type").notNull(), - productType: productTypes("product_type").notNull(), - id: uuid("id").notNull().primaryKey().defaultRandom(), - paymentProvider: paymentProviders("payment_provider"), providerPriceId: text("provider_price_id"), providerProductId: text("provider_product_id"), + productType: productTypes("product_type").notNull(), + paymentProvider: paymentProviders("payment_provider"), + id: uuid("id").notNull().primaryKey().defaultRandom(), renewOn: timestamp("renew_on", { withTimezone: true }), cancelledOn: timestamp("cancelled_on", { withTimezone: true }), customerId: uuid("customer_id") From aad15f654c92be22465c004336b7ddd3ea3b9835 Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sun, 9 Aug 2026 13:25:58 +0530 Subject: [PATCH 09/11] refactor: simplify price creation functions in payment catalog --- apps/website/app/lib/payment-catalog.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/website/app/lib/payment-catalog.ts b/apps/website/app/lib/payment-catalog.ts index b695e32f66e..dea5450328b 100644 --- a/apps/website/app/lib/payment-catalog.ts +++ b/apps/website/app/lib/payment-catalog.ts @@ -30,14 +30,14 @@ const paddlePrice = ( name: TPlanTypes, priceId: string, metadata: PricingMetadata = {}, -): PaymentPrice => ({ name, priceId, ...metadata }); +) => ({ name, priceId, ...metadata }); const polarPrice = ( name: TPlanTypes, productId: string, priceId: string, metadata: PricingMetadata = {}, -): PaymentPrice => ({ name, productId, priceId, ...metadata }); +) => ({ name, productId, priceId, ...metadata }); export const PAYMENT_CATALOG: PaymentCatalog = { paddle: { From ed7af5c7c673cebd8e0c6e134fee12f01b2ad383 Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sun, 9 Aug 2026 13:28:18 +0530 Subject: [PATCH 10/11] fix: correct hyphenation in pricing details and self-hosting section --- .../content/blog/ryot-pricing-is-changing/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/website/app/content/blog/ryot-pricing-is-changing/index.mdx b/apps/website/app/content/blog/ryot-pricing-is-changing/index.mdx index d7e8d774254..caa955b7b3d 100644 --- a/apps/website/app/content/blog/ryot-pricing-is-changing/index.mdx +++ b/apps/website/app/content/blog/ryot-pricing-is-changing/index.mdx @@ -21,7 +21,7 @@ export const tableOfContents = [ ]; From 9 August 2026, Ryot Pro costs more than it used to. Cloud goes from $3 to $6 a -month. Self hosted goes from $2 to $4. +month. Self-hosted goes from $2 to $4. If you are already paying for Ryot, none of that applies to you. You keep the price you signed up at, for as long as your subscription stays active. There is nothing you need @@ -37,13 +37,13 @@ exclusive of any taxes that apply where you live. - Monthly: $3 becomes $6 - Yearly: $30 becomes $50 -**Self hosted**, where you run Ryot on your own server: +**Self-hosted**, where you run Ryot on your own server: - Monthly: $2 becomes $4 - Yearly: $20 becomes $35 - Lifetime: $60 becomes $120 -Self hosting Ryot itself is still free. The application is open source, and this change +Self-hosting Ryot itself is still free. The application is open source, and this change does not move any existing free functionality behind the paid tier. Pro is a layer on top of that, and it stays optional. @@ -110,9 +110,9 @@ is worth knowing that this is the one thing that ends the old rate. Not at all. Lifetime means lifetime. -**Does this change anything about self hosting Ryot for free?** +**Does this change anything about self-hosting Ryot for free?** -No. The open source application stays free to self host, and nothing that is free today +No. The open source application stays free to self-host, and nothing that is free today moves behind Pro because of this change. **Something on my bill looks wrong. What do I do?** From 4e0a8926c3fb687491900021f81a07f8cb665959 Mon Sep 17 00:00:00 2001 From: Diptesh Choudhuri Date: Sun, 9 Aug 2026 13:37:45 +0530 Subject: [PATCH 11/11] fix: update contact information section and improve layout --- apps/website/app/routes/terms.tsx | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/website/app/routes/terms.tsx b/apps/website/app/routes/terms.tsx index 0c3867433ff..08aaad8f7a7 100644 --- a/apps/website/app/routes/terms.tsx +++ b/apps/website/app/routes/terms.tsx @@ -1,4 +1,4 @@ -import { FileText, Mail, MapPin, Scale } from "lucide-react"; +import { FileText, Mail, Scale } from "lucide-react"; import { SectionHeader } from "~/lib/components/SectionHeader"; import { TermsSection } from "~/lib/components/TermsSection"; import { Card, CardContent } from "~/lib/components/ui/card"; @@ -37,24 +37,21 @@ export default function Index() {

- +
-
- +
+ - Ryot, Pocket A-3, Kalkaji Extension, New Delhi 110019, - Delhi, India + For support and legal notices, contact us at{" "} + + {contactEmail} + + .
-