This commit is contained in:
Shawn Erquhart
2025-01-25 18:05:14 -05:00
parent 3ff82d9b60
commit aa0e03e8f6
24 changed files with 687 additions and 893 deletions

17
example/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -42,66 +42,80 @@ export declare const internal: FilterApi<
export declare const components: { export declare const components: {
polar: { polar: {
lib: { lib: {
getBenefit: FunctionReference< createProduct: FunctionReference<
"query", "mutation",
"internal", "internal",
{ id: string },
{ {
_creationTime: number; product: {
_id: string; createdAt: string;
createdAt: string; description: string | null;
deletable: boolean; id: string;
description: string; isArchived: boolean;
id: string; isRecurring: boolean;
modifiedAt: string | null; medias: Array<{
organizationId: string; checksumEtag: string | null;
properties: Record<string, any>; checksumSha256Base64: string | null;
selectable: boolean; checksumSha256Hex: string | null;
type?: string; createdAt: string;
} | null id: string;
isUploaded: boolean;
lastModifiedAt: string | null;
mimeType: string;
name: string;
organizationId: string;
path: string;
publicUrl: string;
service?: string;
size: number;
sizeReadable: string;
storageVersion: string | null;
version: string | null;
}>;
modifiedAt: string | null;
name: string;
organizationId: string;
prices: Array<{
amountType?: string;
createdAt: string;
id: string;
isArchived: boolean;
modifiedAt: string | null;
priceAmount?: number;
priceCurrency?: string;
productId: string;
recurringInterval?: string;
type?: string;
}>;
};
},
any
>; >;
getBenefitGrant: FunctionReference< createSubscription: FunctionReference<
"query", "mutation",
"internal", "internal",
{ id: string },
{ {
_creationTime: number; callback?: string;
_id: string; subscription: {
benefitId: string; amount: number | null;
createdAt: string; cancelAtPeriodEnd: boolean;
grantedAt: string | null; checkoutId: string | null;
id: string; createdAt: string;
isGranted: boolean; currency: string | null;
isRevoked: boolean; currentPeriodEnd: string | null;
modifiedAt: string | null; currentPeriodStart: string;
orderId: string | null; endedAt: string | null;
properties: Record<string, any>; id: string;
revokedAt: string | null; metadata: Record<string, any>;
subscriptionId: string | null; modifiedAt: string | null;
userId: string; priceId: string;
} | null productId: string;
>; recurringInterval: string;
getOrder: FunctionReference< startedAt: string | null;
"query", status: string;
"internal", userId: string;
{ id: string }, };
{ },
_creationTime: number; any
_id: string;
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
} | null
>; >;
getProduct: FunctionReference< getProduct: FunctionReference<
"query", "query",
@@ -177,24 +191,6 @@ export declare const components: {
userId: string; userId: string;
} | null } | null
>; >;
listBenefits: FunctionReference<
"query",
"internal",
{},
Array<{
_creationTime: number;
_id: string;
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
}>
>;
listProducts: FunctionReference< listProducts: FunctionReference<
"query", "query",
"internal", "internal",
@@ -243,27 +239,6 @@ export declare const components: {
}>; }>;
}> }>
>; >;
listUserBenefitGrants: FunctionReference<
"query",
"internal",
{ userId: string },
Array<{
_creationTime: number;
_id: string;
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
}>
>;
listUserSubscriptions: FunctionReference< listUserSubscriptions: FunctionReference<
"query", "query",
"internal", "internal",
@@ -333,67 +308,6 @@ export declare const components: {
userId: string; userId: string;
}> }>
>; >;
updateBenefit: FunctionReference<
"mutation",
"internal",
{
benefit: {
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
};
},
any
>;
updateBenefitGrant: FunctionReference<
"mutation",
"internal",
{
benefitGrant: {
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
};
},
any
>;
updateOrder: FunctionReference<
"mutation",
"internal",
{
order: {
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
};
},
any
>;
updateProduct: FunctionReference< updateProduct: FunctionReference<
"mutation", "mutation",
"internal", "internal",

View File

@@ -1,58 +1,6 @@
import { Polar } from "@convex-dev/polar"; import { Polar } from "@convex-dev/polar";
import { v } from "convex/values"; import { v } from "convex/values";
import { WebhookSubscriptionCreatedPayload$inboundSchema } from "@polar-sh/sdk/models/components"; import { query } from "./_generated/server";
import { query, internalMutation } from "./_generated/server";
import { components } from "./_generated/api"; import { components } from "./_generated/api";
import { Id } from "./_generated/dataModel";
const polar = new Polar(components.polar); export const polar = new Polar(components.polar);
export const listProducts = query({
args: {},
handler: async (ctx) => {
return ctx.runQuery(polar.component.lib.listProducts, {
includeArchived: false,
});
},
});
export const listUserSubscriptions = query({
args: {
userId: v.string(),
},
handler: async (ctx, args) => {
return ctx.runQuery(polar.component.lib.listUserSubscriptions, {
userId: args.userId,
});
},
});
/**
* This function is called when a Polar webhook is received.
*
* The payload is provided as received from Polar, and the webhook signature is
* already verified before this function is called.
*/
export const polarEventCallback = internalMutation({
args: {
payload: v.any(),
},
handler: async (ctx, args) => {
switch (args.payload.type) {
// When creating a subscription, pass the user's id from your system into
// the metadata field. The same metadata will be passed back in the
// webhook, allowing you to add the user's Polar ID to the record in
// your database.
case "subscription.created": {
const payload = WebhookSubscriptionCreatedPayload$inboundSchema.parse(
args.payload
);
const userId = payload.data.metadata.userId;
await ctx.db.patch(userId as Id<"users">, {
polarId: payload.data.userId,
});
break;
}
}
},
});

View File

@@ -1,14 +1,14 @@
import { Polar } from "@convex-dev/polar"; import { Polar } from "@convex-dev/polar";
import { httpRouter } from "convex/server"; import { httpRouter } from "convex/server";
import { components, internal } from "./_generated/api"; import { components } from "./_generated/api";
const http = httpRouter(); const http = httpRouter();
const polar = new Polar(components.polar); const polar = new Polar(components.polar);
polar.registerRoutes(http, { polar.registerRoutes(http, {
// Optional custom path, default is "/events/polar"
path: "/events/polar", path: "/events/polar",
eventCallback: internal.example.polarEventCallback,
}); });
export default http; export default http;

View File

@@ -4,16 +4,28 @@
"type": "module", "type": "module",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"dev": "convex dev --live-component-sources --typecheck-components", "dev": "convex dev --live-component-sources --typecheck-components --tail-logs",
"dev:frontend": "vite", "dev:frontend": "vite",
"logs": "convex logs", "logs": "convex logs",
"lint": "tsc -p convex && eslint convex" "lint": "tsc -p convex && eslint convex"
}, },
"dependencies": { "dependencies": {
"@convex-dev/polar": "file:..", "@convex-dev/polar": "file:..",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-table": "^8.20.6",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"convex": "file:../node_modules/convex", "convex": "file:../node_modules/convex",
"convex-helpers": "^0.1.67",
"lucide-react": "^0.471.0",
"next-themes": "^0.4.4",
"postcss": "^8.4.49",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.17",
"use-debounce": "^10.0.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,23 +1,95 @@
import "./App.css"; import { useState } from "react";
import { useMutation, useQuery } from "convex/react"; import { Input } from "@/components/ui/input";
import { api } from "../convex/_generated/api"; import { Button } from "@/components/ui/button";
import { UpgradeCTA } from "./UpgradeCta";
import { AlertCircle } from "lucide-react";
function App() { interface Todo {
const count = useQuery(api.example.getCount); id: number;
const addOne = useMutation(api.example.addOne); text: string;
completed: boolean;
return (
<>
<h1>Convex Polar Component Example</h1>
<div className="card">
<button onClick={() => addOne()}>count is {count}</button>
<p>
See <code>example/convex/example.ts</code> for all the ways to use
this component
</p>
</div>
</>
);
} }
export default App; export default function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTodo, setNewTodo] = useState("");
const [isPremium, setIsPremium] = useState(false); // This would typically be set based on user's subscription status
const MAX_FREE_TODOS = 5;
const addTodo = (e: React.FormEvent) => {
e.preventDefault();
if (newTodo.trim()) {
if (!isPremium && todos.length >= MAX_FREE_TODOS) {
alert(
"You've reached the maximum number of todos for the free plan. Please upgrade to add more!"
);
return;
}
setTodos([
...todos,
{ id: Date.now(), text: newTodo.trim(), completed: false },
]);
setNewTodo("");
}
};
const toggleTodo = (id: number) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id: number) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-light mb-6 text-gray-800">Todo List</h1>
<form onSubmit={addTodo} className="mb-6">
<Input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a new task"
className="w-full text-lg py-2 border-b border-gray-300 focus:border-gray-500 transition-colors duration-300"
/>
</form>
{!isPremium && todos.length >= MAX_FREE_TODOS && (
<div className="flex items-center text-yellow-600 mb-4">
<AlertCircle className="mr-2" />
<span>
You've reached the limit for free todos. Upgrade to add more!
</span>
</div>
)}
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between py-2 border-b border-gray-200"
>
<button
onClick={() => toggleTodo(todo.id)}
className={`text-lg flex-grow text-left ${todo.completed ? "line-through text-gray-400" : "text-gray-800"}`}
>
{todo.text}
</button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteTodo(todo.id)}
className="text-gray-400 hover:text-red-500"
>
Delete
</Button>
</li>
))}
</ul>
{!isPremium && <UpgradeCTA />}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Button } from "@/components/ui/button";
import { ArrowRight } from "lucide-react";
export function UpgradeCTA() {
return (
<div className="bg-gradient-to-r from-purple-500 to-indigo-500 text-white p-4 rounded-lg shadow-md mt-8">
<h2 className="text-xl font-semibold mb-2">Upgrade to Premium</h2>
<p className="mb-4">Get unlimited todos, priority support, and more!</p>
<Button
variant="secondary"
className="bg-white text-purple-700 hover:bg-gray-100"
>
Upgrade Now <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -1,68 +1,78 @@
:root { @tailwind base;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; @tailwind components;
line-height: 1.5; @tailwind utilities;
font-weight: 400;
color-scheme: light dark; /* To change the theme colors, change the values below
color: rgba(255, 255, 255, 0.87); or use the "Copy code" button at https://ui.shadcn.com/themes */
background-color: #242424; @layer base {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root { :root {
color: #213547; --background: 0 0% 100%;
background-color: #ffffff; --foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
} }
a:hover {
color: #747bff; .dark {
} --background: 20 14.3% 4.1%;
button { --foreground: 60 9.1% 97.8%;
background-color: #f9f9f9;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
} }
} }

6
example/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,17 +1,18 @@
import { StrictMode } from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import ReactDOM from "react-dom/client";
import { ThemeProvider } from "next-themes";
import { ConvexProvider, ConvexReactClient } from "convex/react"; import { ConvexProvider, ConvexReactClient } from "convex/react";
import App from "./App.tsx"; import App from "./App.tsx";
import "./index.css"; import "./index.css";
const address = import.meta.env.VITE_CONVEX_URL; const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
const convex = new ConvexReactClient(address); ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
createRoot(document.getElementById("root")!).render( <ThemeProvider attribute="class">
<StrictMode> <ConvexProvider client={convex}>
<ConvexProvider client={convex}> <App />
<App /> </ConvexProvider>
</ConvexProvider> </ThemeProvider>
</StrictMode> </React.StrictMode>
); );

View File

@@ -0,0 +1,77 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "selector",
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
safelist: ["dark"],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
sm: "1000px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
};

View File

@@ -14,6 +14,11 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Import paths */
"paths": {
"@/*": ["./src/*"]
},
/* This should only be used in this example. Real apps should not attempt /* This should only be used in this example. Real apps should not attempt
* to compile TypeScript because differences between tsconfig.json files can * to compile TypeScript because differences between tsconfig.json files can
* cause the code to be compiled differently. * cause the code to be compiled differently.

View File

@@ -1,10 +1,13 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import path from "path";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
conditions: ["@convex-dev/component-source"], conditions: ["@convex-dev/component-source"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
}, },
}); });

View File

@@ -67,8 +67,8 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"convex": "^1.17.0", "@polar-sh/sdk": "^0.20.2",
"@polar-sh/sdk": "~0.16.0" "convex": "^1.18.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.1", "@eslint/js": "^9.9.1",
@@ -86,6 +86,7 @@
"types": "./dist/commonjs/client/index.d.ts", "types": "./dist/commonjs/client/index.d.ts",
"module": "./dist/esm/client/index.js", "module": "./dist/esm/client/index.js",
"dependencies": { "dependencies": {
"buffer": "^6.0.3",
"convex-helpers": "^0.1.63", "convex-helpers": "^0.1.63",
"standardwebhooks": "^1.0.0" "standardwebhooks": "^1.0.0"
} }

View File

@@ -1,60 +1,55 @@
import "./polyfill";
import { import {
type WebhookBenefitCreatedPayload$Outbound, FunctionReference,
WebhookBenefitCreatedPayload$inboundSchema,
type WebhookBenefitGrantCreatedPayload$Outbound,
WebhookBenefitGrantCreatedPayload$inboundSchema,
type WebhookBenefitGrantUpdatedPayload$Outbound,
WebhookBenefitGrantUpdatedPayload$inboundSchema,
type WebhookBenefitUpdatedPayload$Outbound,
WebhookBenefitUpdatedPayload$inboundSchema,
type WebhookOrderCreatedPayload$Outbound,
WebhookOrderCreatedPayload$inboundSchema,
type WebhookProductCreatedPayload$Outbound,
WebhookProductCreatedPayload$inboundSchema,
type WebhookProductUpdatedPayload$Outbound,
WebhookProductUpdatedPayload$inboundSchema,
type WebhookSubscriptionCreatedPayload$Outbound,
WebhookSubscriptionCreatedPayload$inboundSchema,
type WebhookSubscriptionUpdatedPayload$Outbound,
WebhookSubscriptionUpdatedPayload$inboundSchema,
} from "@polar-sh/sdk/models/components";
import {
type FunctionReference,
type HttpRouter, type HttpRouter,
WithoutSystemFields,
createFunctionHandle, createFunctionHandle,
httpActionGeneric, httpActionGeneric,
} from "convex/server"; } from "convex/server";
import { Webhook } from "standardwebhooks";
import { import {
type ComponentApi, type ComponentApi,
convertToDatabaseBenefit,
convertToDatabaseBenefitGrant,
convertToDatabaseOrder,
convertToDatabaseProduct, convertToDatabaseProduct,
convertToDatabaseSubscription, convertToDatabaseSubscription,
RunMutationCtx,
RunQueryCtx, RunQueryCtx,
} from "../component/util"; } from "../component/util";
import {
validateEvent,
WebhookVerificationError,
} from "@polar-sh/sdk/webhooks";
import {
WebhookSubscriptionCreatedPayload,
WebhookSubscriptionUpdatedPayload,
WebhookProductCreatedPayload,
WebhookProductUpdatedPayload,
} from "@polar-sh/sdk/models/components";
import { Doc } from "../component/_generated/dataModel";
import { Infer } from "convex/values";
import schema from "../component/schema";
export type EventType = ( export const subscriptionValidator = schema.tables.subscriptions.validator;
| WebhookOrderCreatedPayload$Outbound export type Subscription = Infer<typeof subscriptionValidator>;
| WebhookSubscriptionCreatedPayload$Outbound
| WebhookSubscriptionUpdatedPayload$Outbound
| WebhookBenefitCreatedPayload$Outbound
| WebhookBenefitUpdatedPayload$Outbound
| WebhookProductCreatedPayload$Outbound
| WebhookProductUpdatedPayload$Outbound
| WebhookBenefitGrantCreatedPayload$Outbound
| WebhookBenefitGrantUpdatedPayload$Outbound
)["type"];
export type EventHandler = FunctionReference< export type SubscriptionHandler = FunctionReference<
"mutation", "mutation",
"internal", "internal",
{ payload: unknown } { subscription: Subscription }
>; >;
export class Polar { export class Polar {
constructor(public component: ComponentApi) {} public onScheduleCreated?: SubscriptionHandler;
constructor(
public component: ComponentApi,
options: {
onScheduleCreated?: FunctionReference<
"mutation",
"internal",
{ subscription: WithoutSystemFields<Doc<"subscriptions">> }
>;
} = {}
) {
this.onScheduleCreated = options.onScheduleCreated;
}
listProducts( listProducts(
ctx: RunQueryCtx, ctx: RunQueryCtx,
{ includeArchived }: { includeArchived: boolean } { includeArchived }: { includeArchived: boolean }
@@ -67,30 +62,32 @@ export class Polar {
getSubscription(ctx: RunQueryCtx, { id }: { id: string }) { getSubscription(ctx: RunQueryCtx, { id }: { id: string }) {
return ctx.runQuery(this.component.lib.getSubscription, { id }); return ctx.runQuery(this.component.lib.getSubscription, { id });
} }
getOrder(ctx: RunQueryCtx, { id }: { id: string }) {
return ctx.runQuery(this.component.lib.getOrder, { id });
}
getBenefit(ctx: RunQueryCtx, { id }: { id: string }) {
return ctx.runQuery(this.component.lib.getBenefit, { id });
}
getBenefitGrant(ctx: RunQueryCtx, { id }: { id: string }) {
return ctx.runQuery(this.component.lib.getBenefitGrant, { id });
}
getProduct(ctx: RunQueryCtx, { id }: { id: string }) { getProduct(ctx: RunQueryCtx, { id }: { id: string }) {
return ctx.runQuery(this.component.lib.getProduct, { id }); return ctx.runQuery(this.component.lib.getProduct, { id });
} }
listBenefits(ctx: RunQueryCtx) {
return ctx.runQuery(this.component.lib.listBenefits);
}
listUserBenefitGrants(ctx: RunQueryCtx, { userId }: { userId: string }) {
return ctx.runQuery(this.component.lib.listUserBenefitGrants, { userId });
}
registerRoutes( registerRoutes(
http: HttpRouter, http: HttpRouter,
{ {
path = "/polar/events", path = "/polar/events",
eventCallback, }: {
}: { eventCallback?: EventHandler; path?: string } = {} path?: string;
onSubscriptionCreated?: (
ctx: RunMutationCtx,
event: WebhookSubscriptionCreatedPayload
) => Promise<void>;
onSubscriptionUpdated?: (
ctx: RunMutationCtx,
event: WebhookSubscriptionUpdatedPayload
) => Promise<void>;
onProductCreated?: (
ctx: RunMutationCtx,
event: WebhookProductCreatedPayload
) => Promise<void>;
onProductUpdated?: (
ctx: RunMutationCtx,
event: WebhookProductUpdatedPayload
) => Promise<void>;
} = {}
) { ) {
http.route({ http.route({
path, path,
@@ -100,78 +97,45 @@ export class Polar {
throw new Error("No body"); throw new Error("No body");
} }
const body = await request.text(); const body = await request.text();
const wh = new Webhook(btoa(process.env.POLAR_WEBHOOK_SECRET!));
const headers = Object.fromEntries(request.headers.entries()); const headers = Object.fromEntries(request.headers.entries());
const payload = wh.verify(body, headers) as { try {
type: EventType; const event = validateEvent(
data: unknown; body,
}; headers,
process.env["POLAR_WEBHOOK_SECRET"] ?? ""
switch (payload.type) { );
case "order.created": { switch (event.type) {
await ctx.runMutation(this.component.lib.updateOrder, { case "subscription.created": {
order: convertToDatabaseOrder( await ctx.runMutation(this.component.lib.createSubscription, {
WebhookOrderCreatedPayload$inboundSchema.parse(payload).data subscription: convertToDatabaseSubscription(event.data),
), callback:
}); this.onScheduleCreated &&
break; (await createFunctionHandle(this.onScheduleCreated)),
});
break;
}
case "subscription.updated": {
await ctx.runMutation(this.component.lib.updateSubscription, {
subscription: convertToDatabaseSubscription(event.data),
});
break;
}
case "product.created":
case "product.updated": {
await ctx.runMutation(this.component.lib.updateProduct, {
product: convertToDatabaseProduct(event.data),
});
break;
}
} }
case "subscription.created": return new Response("Accepted", { status: 202 });
case "subscription.updated": { } catch (error) {
const schema = if (error instanceof WebhookVerificationError) {
payload.type === "subscription.created" console.error(error);
? WebhookSubscriptionCreatedPayload$inboundSchema return new Response("Forbidden", { status: 403 });
: WebhookSubscriptionUpdatedPayload$inboundSchema;
await ctx.runMutation(this.component.lib.updateSubscription, {
subscription: convertToDatabaseSubscription(
schema.parse(payload).data
),
});
break;
}
case "product.created":
case "product.updated": {
const schema =
payload.type === "product.created"
? WebhookProductCreatedPayload$inboundSchema
: WebhookProductUpdatedPayload$inboundSchema;
await ctx.runMutation(this.component.lib.updateProduct, {
product: convertToDatabaseProduct(schema.parse(payload).data),
});
break;
}
case "benefit.created":
case "benefit.updated": {
const schema =
payload.type === "benefit.created"
? WebhookBenefitCreatedPayload$inboundSchema
: WebhookBenefitUpdatedPayload$inboundSchema;
await ctx.runMutation(this.component.lib.updateBenefit, {
benefit: convertToDatabaseBenefit(schema.parse(payload).data),
});
break;
}
case "benefit_grant.created":
case "benefit_grant.updated": {
const schema =
payload.type === "benefit_grant.created"
? WebhookBenefitGrantCreatedPayload$inboundSchema
: WebhookBenefitGrantUpdatedPayload$inboundSchema;
await ctx.runMutation(this.component.lib.updateBenefitGrant, {
benefitGrant: convertToDatabaseBenefitGrant(
schema.parse(payload).data
),
});
break;
} }
throw error;
} }
if (eventCallback) {
await ctx.runMutation(await createFunctionHandle(eventCallback), {
payload,
});
}
return new Response("OK", { status: 200 });
}), }),
}); });
} }

2
src/client/polyfill.ts Normal file
View File

@@ -0,0 +1,2 @@
import { Buffer as BufferPolyfill } from "buffer";
globalThis.Buffer = BufferPolyfill;

View File

@@ -30,66 +30,80 @@ declare const fullApi: ApiFromModules<{
}>; }>;
export type Mounts = { export type Mounts = {
lib: { lib: {
getBenefit: FunctionReference< createProduct: FunctionReference<
"query", "mutation",
"public", "public",
{ id: string },
{ {
_creationTime: number; product: {
_id: string; createdAt: string;
createdAt: string; description: string | null;
deletable: boolean; id: string;
description: string; isArchived: boolean;
id: string; isRecurring: boolean;
modifiedAt: string | null; medias: Array<{
organizationId: string; checksumEtag: string | null;
properties: Record<string, any>; checksumSha256Base64: string | null;
selectable: boolean; checksumSha256Hex: string | null;
type?: string; createdAt: string;
} | null id: string;
isUploaded: boolean;
lastModifiedAt: string | null;
mimeType: string;
name: string;
organizationId: string;
path: string;
publicUrl: string;
service?: string;
size: number;
sizeReadable: string;
storageVersion: string | null;
version: string | null;
}>;
modifiedAt: string | null;
name: string;
organizationId: string;
prices: Array<{
amountType?: string;
createdAt: string;
id: string;
isArchived: boolean;
modifiedAt: string | null;
priceAmount?: number;
priceCurrency?: string;
productId: string;
recurringInterval?: string;
type?: string;
}>;
};
},
any
>; >;
getBenefitGrant: FunctionReference< createSubscription: FunctionReference<
"query", "mutation",
"public", "public",
{ id: string },
{ {
_creationTime: number; callback?: string;
_id: string; subscription: {
benefitId: string; amount: number | null;
createdAt: string; cancelAtPeriodEnd: boolean;
grantedAt: string | null; checkoutId: string | null;
id: string; createdAt: string;
isGranted: boolean; currency: string | null;
isRevoked: boolean; currentPeriodEnd: string | null;
modifiedAt: string | null; currentPeriodStart: string;
orderId: string | null; endedAt: string | null;
properties: Record<string, any>; id: string;
revokedAt: string | null; metadata: Record<string, any>;
subscriptionId: string | null; modifiedAt: string | null;
userId: string; priceId: string;
} | null productId: string;
>; recurringInterval: string;
getOrder: FunctionReference< startedAt: string | null;
"query", status: string;
"public", userId: string;
{ id: string }, };
{ },
_creationTime: number; any
_id: string;
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
} | null
>; >;
getProduct: FunctionReference< getProduct: FunctionReference<
"query", "query",
@@ -165,24 +179,6 @@ export type Mounts = {
userId: string; userId: string;
} | null } | null
>; >;
listBenefits: FunctionReference<
"query",
"public",
{},
Array<{
_creationTime: number;
_id: string;
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
}>
>;
listProducts: FunctionReference< listProducts: FunctionReference<
"query", "query",
"public", "public",
@@ -231,27 +227,6 @@ export type Mounts = {
}>; }>;
}> }>
>; >;
listUserBenefitGrants: FunctionReference<
"query",
"public",
{ userId: string },
Array<{
_creationTime: number;
_id: string;
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
}>
>;
listUserSubscriptions: FunctionReference< listUserSubscriptions: FunctionReference<
"query", "query",
"public", "public",
@@ -321,67 +296,6 @@ export type Mounts = {
userId: string; userId: string;
}> }>
>; >;
updateBenefit: FunctionReference<
"mutation",
"public",
{
benefit: {
createdAt: string;
deletable: boolean;
description: string;
id: string;
modifiedAt: string | null;
organizationId: string;
properties: Record<string, any>;
selectable: boolean;
type?: string;
};
},
any
>;
updateBenefitGrant: FunctionReference<
"mutation",
"public",
{
benefitGrant: {
benefitId: string;
createdAt: string;
grantedAt: string | null;
id: string;
isGranted: boolean;
isRevoked: boolean;
modifiedAt: string | null;
orderId: string | null;
properties: Record<string, any>;
revokedAt: string | null;
subscriptionId: string | null;
userId: string;
};
},
any
>;
updateOrder: FunctionReference<
"mutation",
"public",
{
order: {
amount: number;
billingReason: string;
checkoutId: string | null;
createdAt: string;
currency: string;
id: string;
metadata: Record<string, any>;
modifiedAt: string | null;
productId: string | null;
productPriceId: string;
subscriptionId: string | null;
taxAmount: number;
userId: string | null;
};
},
any
>;
updateProduct: FunctionReference< updateProduct: FunctionReference<
"mutation", "mutation",
"public", "public",

View File

@@ -1,7 +1,9 @@
import { v } from "convex/values"; import { v, VString } from "convex/values";
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import schema from "./schema"; import schema from "./schema";
import { asyncMap } from "convex-helpers"; import { asyncMap } from "convex-helpers";
import { FunctionHandle, WithoutSystemFields } from "convex/server";
import { Doc } from "./_generated/dataModel";
export const getSubscription = query({ export const getSubscription = query({
args: { args: {
@@ -23,26 +25,6 @@ export const getSubscription = query({
}, },
}); });
export const getOrder = query({
args: {
id: v.id("orders"),
},
returns: v.union(
v.object({
...schema.tables.orders.validator.fields,
_id: v.id("orders"),
_creationTime: v.number(),
}),
v.null()
),
handler: async (ctx, args) => {
return ctx.db
.query("orders")
.withIndex("id", (q) => q.eq("id", args.id))
.unique();
},
});
export const getProduct = query({ export const getProduct = query({
args: { args: {
id: v.id("products"), id: v.id("products"),
@@ -126,20 +108,21 @@ export const listProducts = query({
}, },
}); });
export const updateOrder = mutation({ type Subscription = WithoutSystemFields<Doc<"subscriptions">>;
const subscriptionCallbackValidator = v.string() as VString<
FunctionHandle<"mutation", { subscription: Subscription }>
>;
export const createSubscription = mutation({
args: { args: {
order: schema.tables.orders.validator, subscription: schema.tables.subscriptions.validator,
callback: v.optional(subscriptionCallbackValidator),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const existingOrder = await ctx.db await ctx.db.insert("subscriptions", args.subscription);
.query("orders") if (args.callback) {
.withIndex("id", (q) => q.eq("id", args.order.id)) await ctx.runMutation(args.callback, { subscription: args.subscription });
.unique();
if (existingOrder) {
await ctx.db.patch(existingOrder._id, args.order);
return;
} }
await ctx.db.insert("orders", args.order);
}, },
}); });
@@ -152,11 +135,19 @@ export const updateSubscription = mutation({
.query("subscriptions") .query("subscriptions")
.withIndex("id", (q) => q.eq("id", args.subscription.id)) .withIndex("id", (q) => q.eq("id", args.subscription.id))
.unique(); .unique();
if (existingSubscription) { if (!existingSubscription) {
await ctx.db.patch(existingSubscription._id, args.subscription); throw new Error(`Subscription not found: ${args.subscription.id}`);
return;
} }
await ctx.db.insert("subscriptions", args.subscription); await ctx.db.patch(existingSubscription._id, args.subscription);
},
});
export const createProduct = mutation({
args: {
product: schema.tables.products.validator,
},
handler: async (ctx, args) => {
await ctx.db.insert("products", args.product);
}, },
}); });
@@ -169,117 +160,9 @@ export const updateProduct = mutation({
.query("products") .query("products")
.withIndex("id", (q) => q.eq("id", args.product.id)) .withIndex("id", (q) => q.eq("id", args.product.id))
.unique(); .unique();
if (existingProduct) { if (!existingProduct) {
await ctx.db.patch(existingProduct._id, args.product); throw new Error(`Product not found: ${args.product.id}`);
return;
} }
await ctx.db.insert("products", args.product); await ctx.db.patch(existingProduct._id, args.product);
},
});
export const updateBenefit = mutation({
args: {
benefit: schema.tables.benefits.validator,
},
handler: async (ctx, args) => {
const existingBenefit = await ctx.db
.query("benefits")
.withIndex("id", (q) => q.eq("id", args.benefit.id))
.unique();
if (existingBenefit) {
await ctx.db.patch(existingBenefit._id, args.benefit);
return;
}
await ctx.db.insert("benefits", args.benefit);
},
});
export const getBenefit = query({
args: {
id: v.id("benefits"),
},
returns: v.union(
v.object({
...schema.tables.benefits.validator.fields,
_id: v.id("benefits"),
_creationTime: v.number(),
}),
v.null()
),
handler: async (ctx, args) => {
return ctx.db
.query("benefits")
.withIndex("id", (q) => q.eq("id", args.id))
.unique();
},
});
export const listBenefits = query({
args: {},
returns: v.array(
v.object({
...schema.tables.benefits.validator.fields,
_id: v.id("benefits"),
_creationTime: v.number(),
})
),
handler: async (ctx) => {
return ctx.db.query("benefits").collect();
},
});
export const updateBenefitGrant = mutation({
args: {
benefitGrant: schema.tables.benefitGrants.validator,
},
handler: async (ctx, args) => {
const existingBenefitGrant = await ctx.db
.query("benefitGrants")
.withIndex("id", (q) => q.eq("id", args.benefitGrant.id))
.unique();
if (existingBenefitGrant) {
await ctx.db.patch(existingBenefitGrant._id, args.benefitGrant);
return;
}
await ctx.db.insert("benefitGrants", args.benefitGrant);
},
});
export const getBenefitGrant = query({
args: {
id: v.id("benefitGrants"),
},
returns: v.union(
v.object({
...schema.tables.benefitGrants.validator.fields,
_id: v.id("benefitGrants"),
_creationTime: v.number(),
}),
v.null()
),
handler: async (ctx, args) => {
return ctx.db
.query("benefitGrants")
.withIndex("id", (q) => q.eq("id", args.id))
.unique();
},
});
export const listUserBenefitGrants = query({
args: {
userId: v.string(),
},
returns: v.array(
v.object({
...schema.tables.benefitGrants.validator.fields,
_id: v.id("benefitGrants"),
_creationTime: v.number(),
})
),
handler: async (ctx, args) => {
return ctx.db
.query("benefitGrants")
.withIndex("userId", (q) => q.eq("userId", args.userId))
.collect();
}, },
}); });

View File

@@ -3,50 +3,6 @@ import { v } from "convex/values";
export default defineSchema( export default defineSchema(
{ {
benefits: defineTable({
id: v.string(),
createdAt: v.string(),
modifiedAt: v.union(v.string(), v.null()),
organizationId: v.string(),
type: v.optional(v.string()),
description: v.string(),
selectable: v.boolean(),
deletable: v.boolean(),
properties: v.record(v.string(), v.any()),
}).index("id", ["id"]),
benefitGrants: defineTable({
id: v.string(),
createdAt: v.string(),
modifiedAt: v.union(v.string(), v.null()),
userId: v.string(),
benefitId: v.string(),
properties: v.record(v.string(), v.any()),
isGranted: v.boolean(),
isRevoked: v.boolean(),
subscriptionId: v.union(v.string(), v.null()),
orderId: v.union(v.string(), v.null()),
grantedAt: v.union(v.string(), v.null()),
revokedAt: v.union(v.string(), v.null()),
})
.index("id", ["id"])
.index("userId", ["userId"]),
orders: defineTable({
id: v.string(),
createdAt: v.string(),
modifiedAt: v.union(v.string(), v.null()),
userId: v.union(v.string(), v.null()),
productId: v.union(v.string(), v.null()),
productPriceId: v.string(),
subscriptionId: v.union(v.string(), v.null()),
checkoutId: v.union(v.string(), v.null()),
metadata: v.record(v.string(), v.any()),
amount: v.number(),
taxAmount: v.number(),
currency: v.string(),
billingReason: v.string(),
})
.index("id", ["id"])
.index("userId", ["userId"]),
products: defineTable({ products: defineTable({
id: v.string(), id: v.string(),
createdAt: v.string(), createdAt: v.string(),

View File

@@ -1,13 +1,12 @@
import type { GenericQueryCtx, WithoutSystemFields } from "convex/server"; import type {
FunctionHandle,
FunctionType,
GenericQueryCtx,
WithoutSystemFields,
} from "convex/server";
import type { Expand, FunctionReference } from "convex/server"; import type { Expand, FunctionReference } from "convex/server";
import type { import type { Product, Subscription } from "@polar-sh/sdk/models/components";
Benefit,
BenefitGrant,
Order,
Product,
Subscription,
} from "@polar-sh/sdk/models/components";
import type { GenericMutationCtx } from "convex/server"; import type { GenericMutationCtx } from "convex/server";
import type { GenericDataModel } from "convex/server"; import type { GenericDataModel } from "convex/server";
import type { GenericActionCtx } from "convex/server"; import type { GenericActionCtx } from "convex/server";
@@ -25,13 +24,16 @@ export type RunActionCtx = {
runAction: GenericActionCtx<GenericDataModel>["runAction"]; runAction: GenericActionCtx<GenericDataModel>["runAction"];
}; };
export type OpaqueIds<T> = T extends GenericId<infer _T> export type OpaqueIds<T> =
? string T extends GenericId<infer _T>
: T extends (infer U)[] ? string
? OpaqueIds<U>[] : T extends FunctionHandle<FunctionType>
: T extends object ? string
? { [K in keyof T]: OpaqueIds<T[K]> } : T extends (infer U)[]
: T; ? OpaqueIds<U>[]
: T extends object
? { [K in keyof T]: OpaqueIds<T[K]> }
: T;
export type UseApi<API> = Expand<{ export type UseApi<API> = Expand<{
[mod in keyof API]: API[mod] extends FunctionReference< [mod in keyof API]: API[mod] extends FunctionReference<
@@ -53,28 +55,8 @@ export type UseApi<API> = Expand<{
export type ComponentApi = UseApi<typeof api>; export type ComponentApi = UseApi<typeof api>;
export const convertToDatabaseOrder = (
order: Order,
): WithoutSystemFields<Doc<"orders">> => {
return {
id: order.id,
userId: order.userId,
productId: order.productId,
productPriceId: order.productPriceId,
subscriptionId: order.subscriptionId,
checkoutId: order.checkoutId,
createdAt: order.createdAt.toISOString(),
modifiedAt: order.modifiedAt?.toISOString() ?? null,
metadata: order.metadata,
amount: order.amount,
taxAmount: order.taxAmount,
currency: order.currency,
billingReason: order.billingReason,
};
};
export const convertToDatabaseSubscription = ( export const convertToDatabaseSubscription = (
subscription: Subscription, subscription: Subscription
): WithoutSystemFields<Doc<"subscriptions">> => { ): WithoutSystemFields<Doc<"subscriptions">> => {
return { return {
id: subscription.id, id: subscription.id,
@@ -98,7 +80,7 @@ export const convertToDatabaseSubscription = (
}; };
export const convertToDatabaseProduct = ( export const convertToDatabaseProduct = (
product: Product, product: Product
): WithoutSystemFields<Doc<"products">> => { ): WithoutSystemFields<Doc<"products">> => {
return { return {
id: product.id, id: product.id,
@@ -151,38 +133,3 @@ export const convertToDatabaseProduct = (
})), })),
}; };
}; };
export const convertToDatabaseBenefit = (
benefit: Benefit,
): WithoutSystemFields<Doc<"benefits">> => {
return {
id: benefit.id,
organizationId: benefit.organizationId,
description: benefit.description,
selectable: benefit.selectable,
deletable: benefit.deletable,
properties: benefit.properties,
createdAt: benefit.createdAt.toISOString(),
modifiedAt: benefit.modifiedAt?.toISOString() ?? null,
type: benefit.type,
};
};
export const convertToDatabaseBenefitGrant = (
benefitGrant: BenefitGrant,
): WithoutSystemFields<Doc<"benefitGrants">> => {
return {
id: benefitGrant.id,
userId: benefitGrant.userId,
benefitId: benefitGrant.benefitId,
properties: benefitGrant.properties,
isGranted: benefitGrant.isGranted,
isRevoked: benefitGrant.isRevoked,
subscriptionId: benefitGrant.subscriptionId,
orderId: benefitGrant.orderId,
createdAt: benefitGrant.createdAt.toISOString(),
modifiedAt: benefitGrant.modifiedAt?.toISOString() ?? null,
grantedAt: benefitGrant.grantedAt?.toISOString() ?? null,
revokedAt: benefitGrant.revokedAt?.toISOString() ?? null,
};
};