Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/desktop_cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_PUBLISHABLE_KEY }}
VITE_APP_URL: ${{ secrets.VITE_APP_URL }}
VITE_APP_URL: "https://hyprnote.com"
VITE_API_URL: "https://api.hyprnote.com"
VITE_PRO_PRODUCT_ID: ${{ secrets.VITE_PRO_PRODUCT_ID }}
- run: |
mkdir -p target/release/
Expand Down
1 change: 1 addition & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ importOrder:
- "^\\./instrument$"
- "<THIRD_PARTY_MODULES>"
- "^@hypr/(.*)$"
- "^@/(.*)$"
- "^[./]"
importOrderSeparation: true
importOrderSortSpecifiers: true
Expand Down
25 changes: 11 additions & 14 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -236,27 +236,22 @@ tasks:

generated_env_file=".stripe/generated.env"
env_target="apps/api/.env"
temp_fifo=$(mktemp -u)
mkfifo "$temp_fifo"

cleanup() {
rm -f "$temp_fifo" "$generated_env_file"
rm -f "$generated_env_file"
}
trap cleanup EXIT

mkdir -p "$(dirname "$generated_env_file")"

(stripe listen --skip-verify --forward-to http://localhost:8787/webhook/stripe 2>&1 | tee "$temp_fifo") &
STRIPE_PID=$!
secret=$(stripe listen --print-secret | tr -d '\r\n')

awk '
/Your webhook signing secret is whsec_/ {
match($0, /whsec_[a-zA-Z0-9]+/)
secret = substr($0, RSTART, RLENGTH)
print "STRIPE_WEBHOOK_SECRET=\"" secret "\""
exit
}
' "$temp_fifo" > "$generated_env_file"
if [ -z "$secret" ]; then
echo "failed to retrieve Stripe webhook secret" >&2
exit 1
fi

printf 'STRIPE_WEBHOOK_SECRET="%s"\n' "$secret" > "$generated_env_file"

if [ -s "$generated_env_file" ]; then
mkdir -p "$(dirname "$env_target")"
Expand Down Expand Up @@ -311,5 +306,7 @@ tasks:
echo "✓ Webhook secret written to $env_target"
fi

wait $STRIPE_PID
rm -f "$generated_env_file"

stripe listen --skip-verify --forward-to http://localhost:8787/webhook/stripe
EOF
2 changes: 1 addition & 1 deletion apps/api/.env.sample
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
STRIPE_API_KEY=""
STRIPE_WEBHOOK_SIGNING_SECRET=""
STRIPE_WEBHOOK_SECRET=""
OPENROUTER_API_KEY=""

SUPABASE_ANON_KEY=""
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "env-cmd -f ./.env -- bun --hot src/index.ts",
"dev": "env-cmd --silent -f ./.env -- bun --hot src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
14 changes: 1 addition & 13 deletions apps/api/src/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ export async function syncBillingState(customerId: string) {
const userId = getUserIdFromCustomer(customer);

if (!userId) {
console.warn(
`[STRIPE SYNC] Missing user id metadata for customer ${customer.id}`,
);
return;
}

Expand Down Expand Up @@ -71,14 +68,8 @@ export async function syncBillingState(customerId: string) {
.upsert(payload, { onConflict: "user_id" });

if (error) {
throw new Error(
`[STRIPE SYNC] Failed to sync billing for user ${userId}: ${error.message}`,
);
throw error;
}

console.log(
`[STRIPE SYNC] Synced billing for user ${userId} (customer ${customer.id})`,
);
}

export async function syncBillingForStripeEvent(event: Stripe.Event) {
Expand All @@ -89,9 +80,6 @@ export async function syncBillingForStripeEvent(event: Stripe.Event) {
const customerId = getCustomerId(event.data.object);

if (!customerId) {
console.warn(
`[STRIPE WEBHOOK] Missing customer id for event ${event.type}`,
);
return;
}

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/deepgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,9 @@ export const buildDeepgramUrl = (incomingUrl: URL) => {
const target = new URL("wss://api.deepgram.com/v1/listen");

incomingUrl.searchParams.forEach((value, key) => {
target.searchParams.append(key, value);
target.searchParams.set(key, value);
});
target.searchParams.set("model", "nova-3-general");
target.searchParams.set("mip_opt_out", "false");

return target;
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const env = createEnv({
SUPABASE_ANON_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
STRIPE_API_KEY: z.string().min(1),
STRIPE_WEBHOOK_SIGNING_SECRET: z.string().min(1),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
OPENROUTER_API_KEY: z.string().min(1),
DEEPGRAM_API_KEY: z.string().min(1),
},
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@ export const verifyStripeWebhook = createMiddleware<{
}

const body = await c.req.text();

try {
const event = await stripe.webhooks.constructEventAsync(
body,
signature,
env.STRIPE_WEBHOOK_SIGNING_SECRET,
env.STRIPE_WEBHOOK_SECRET,
undefined,
cryptoProvider,
);

c.set("stripeEvent", event);
await next();
} catch (err) {
console.error(err);
const message = err instanceof Error ? err.message : "unknown_error";
return c.text(message, 400);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "env-cmd -f ./.env -- tauri",
"tauri": "env-cmd --silent -f ./.env -- tauri",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"tauri:dev": "env-cmd -f ./.env -- tauri dev",
"tauri:dev": "env-cmd --silent -f ./.env -- tauri dev",
"tauri:build": "tauri build",
"gen:schema": "tsx src/devtool/seed/script.ts"
},
Expand Down
150 changes: 150 additions & 0 deletions apps/desktop/src/billing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useQuery } from "@tanstack/react-query";
import { openUrl } from "@tauri-apps/plugin-opener";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useMemo,
} from "react";
import type Stripe from "stripe";

import { useAuth } from "./auth";
import { env } from "./env";

type BillingRow = {
id: string;
user_id: string;
created_at: string;
updated_at: string;
stripe_customer: Stripe.Customer | null;
stripe_subscription: Stripe.Subscription | null;
};

type BillingData = (BillingRow & { isPro: boolean }) | null;

type BillingContextValue = {
data: BillingData;
isPro: boolean;
isLoading: boolean;
isPending: boolean;
isFetching: boolean;
isRefetching: boolean;
isError: boolean;
error: unknown;
refetch: () => Promise<unknown>;
upgradeToPro: () => void;
};

export type BillingAccess = BillingContextValue;

const BillingContext = createContext<BillingContextValue | null>(null);

export function BillingProvider({ children }: { children: ReactNode }) {
const auth = useAuth();

const {
data: queryData,
isLoading,
isPending,
isFetching,
isRefetching,
isError,
error,
refetch,
} = useQuery({
enabled: !!auth?.supabase && !!auth?.session?.user?.id,
queryKey: ["billing", auth?.session?.user?.id],
queryFn: async (): Promise<BillingData> => {
if (!auth?.supabase || !auth?.session?.user?.id) {
return null;
}

const { data, error } = await auth.supabase
.from("billings")
.select("*")
.eq("user_id", auth.session.user.id)
.maybeSingle();

if (error) {
throw error;
}

if (!data) {
return null;
}

const billing = data as BillingRow;
return {
...billing,
isPro: computeIsPro(billing.stripe_subscription),
};
},
});

const data = queryData ?? null;

const upgradeToPro = useCallback(() => {
openUrl(`${env.VITE_APP_URL}/app/checkout?period=monthly`);
}, [auth]);

const value = useMemo<BillingContextValue>(
() => ({
data,
isPro: !!data?.isPro,
isLoading,
isPending,
isFetching,
isRefetching,
isError,
error,
refetch: () => refetch(),
upgradeToPro,
}),
[
data,
error,
isError,
isFetching,
isLoading,
isPending,
isRefetching,
refetch,
upgradeToPro,
],
);

return (
<BillingContext.Provider value={value}>{children}</BillingContext.Provider>
);
}

export function useBillingAccess() {
const context = useContext(BillingContext);

if (!context) {
throw new Error("useBillingAccess must be used within BillingProvider");
}

return context;
}

function computeIsPro(
subscription: Stripe.Subscription | null | undefined,
): boolean {
if (!subscription) {
return false;
}

const hasValidStatus = ["active", "trialing"].includes(subscription.status);

const hasProProduct = subscription.items.data.some((item) => {
const product = item.price.product;

return typeof product === "string"
? product === env.VITE_PRO_PRODUCT_ID
: product.id === env.VITE_PRO_PRODUCT_ID;
});

return hasValidStatus && hasProProduct;
}
Loading
Loading