diff --git a/apps/api/openapi.gen.json b/apps/api/openapi.gen.json index 5e185754cb..756372c60d 100644 --- a/apps/api/openapi.gen.json +++ b/apps/api/openapi.gen.json @@ -6034,6 +6034,12 @@ "connection_id" ], "properties": { + "account_identity": { + "type": [ + "string", + "null" + ] + }, "connection_id": { "type": "string" }, diff --git a/apps/mobile/src/auth/billing.ts b/apps/mobile/src/auth/billing.ts index a59118bc79..ef6f53d107 100644 --- a/apps/mobile/src/auth/billing.ts +++ b/apps/mobile/src/auth/billing.ts @@ -18,6 +18,8 @@ export type SupabaseJwtPayload = { subscription_status?: SubscriptionStatus | null; trial_end?: number | null; has_payment_method?: boolean | null; + cancel_at_period_end?: boolean | null; + current_period_end?: number | null; }; export type Plan = "free" | "trial" | "pro"; @@ -31,6 +33,8 @@ export type BillingInfo = { isTrialing: boolean; trialEnd: Date | null; trialDaysRemaining: number | null; + cancelAtPeriodEnd: boolean; + currentPeriodEnd: Date | null; plan: Plan; }; @@ -99,6 +103,9 @@ export function deriveBillingInfo( const hasPaidEntitlement = hasEffectiveProEntitlement || hasLiteEntitlement; const plan: Plan = isTrialing ? "trial" : hasPaidEntitlement ? "pro" : "free"; + const currentPeriodEnd = payload?.current_period_end + ? new Date(payload.current_period_end * 1000) + : null; return { entitlements, @@ -109,6 +116,8 @@ export function deriveBillingInfo( isTrialing, trialEnd, trialDaysRemaining, + cancelAtPeriodEnd: payload?.cancel_at_period_end === true, + currentPeriodEnd, plan, }; } diff --git a/apps/web/src/functions/account-shares.ts b/apps/web/src/functions/account-shares.ts index 52f4826b3f..324d6319e2 100644 --- a/apps/web/src/functions/account-shares.ts +++ b/apps/web/src/functions/account-shares.ts @@ -128,3 +128,37 @@ export const restrictMyShare = createServerFn({ method: "POST" }) } return { success: true as const }; }); + +export const deleteMyShares = createServerFn({ method: "POST" }) + .inputValidator( + z.object({ shareIds: z.array(z.string().uuid()).min(1).max(100) }), + ) + .handler(async ({ data }) => { + const supabase = getSupabaseServerClient(); + let failed = 0; + + for (const shareId of data.shareIds) { + const { error } = await supabase.rpc("delete_session_share", { + p_share_id: shareId, + }); + if (error) { + failed += 1; + } + } + + if (failed === data.shareIds.length) { + return { + success: false as const, + message: "Failed to stop sharing your notes", + }; + } + if (failed > 0) { + return { + success: false as const, + message: `Couldn't stop sharing ${failed} ${ + failed === 1 ? "note" : "notes" + }. Try again.`, + }; + } + return { success: true as const }; + }); diff --git a/apps/web/src/functions/billing.ts b/apps/web/src/functions/billing.ts index 88bfdc34ae..beff757d8f 100644 --- a/apps/web/src/functions/billing.ts +++ b/apps/web/src/functions/billing.ts @@ -20,6 +20,7 @@ import { getSupabaseAdminClient, getSupabaseServerClient, } from "@/functions/supabase"; +import { getSubscriptionAccessEnd } from "@/lib/account-plan"; import { addInternalReturnPathSearch, sanitizeInternalReturnPath, @@ -40,6 +41,12 @@ import { startWorkspaceCheckout, type WorkspaceCheckoutContext, } from "@/lib/workspace-checkout"; +import { isYcPromotionCode, normalizeYcPromotionCode } from "@/lib/yc-perk"; +import { + applyYcPromotionToCustomer, + findYcPromotionCodeByCustomerCode, + subscriptionHasYcPerk, +} from "@/lib/yc-perk-apply"; type SupabaseClient = ReturnType; @@ -71,7 +78,7 @@ class TrialCheckoutCreationError extends Error { } } -const getStripeCustomerIdForUser = async ( +export const getStripeCustomerIdForUser = async ( supabase: SupabaseClient, stripe: Stripe, user: AuthUser, @@ -161,11 +168,13 @@ const getProPriceId = (period: "monthly" | "yearly") => { async function getCurrentSubscription( stripe: Stripe, stripeCustomerId: string, + options?: { expandDiscounts?: boolean }, ): Promise { const subscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId, status: "all", limit: 10, + ...(options?.expandDiscounts ? { expand: ["data.discounts"] } : {}), }); return ( @@ -262,6 +271,19 @@ async function ensureStripeCustomerId( return assignedCustomerId; } +const ycPerkReturnSchema = z.enum(["applied", "claimed", "invalid"]); + +function getAccountYcPerkUrl( + scheme: z.infer | undefined, + perk: z.infer, +) { + if (scheme) { + return `${getBillingReturnUrl(scheme)}&perk=${perk}`; + } + + return `${getRequestAppOrigin()}/app/account?perk=${perk}`; +} + async function createCheckoutUrl({ supabase, user, @@ -272,6 +294,7 @@ async function createCheckoutUrl({ trialDays, source = "unknown", returnTo, + promotionCodeId, }: { supabase: SupabaseClient; user: AuthUser & { email?: string | null }; @@ -282,6 +305,7 @@ async function createCheckoutUrl({ trialDays?: number; source?: CheckoutSource; returnTo?: string; + promotionCodeId?: string; }) { const stripe = getStripeClient(); const stripeCustomerId = await ensureStripeCustomerId(supabase, user); @@ -335,7 +359,9 @@ async function createCheckoutUrl({ }, ], mode: "subscription", - allow_promotion_codes: trial ? undefined : true, + ...(promotionCodeId + ? { discounts: [{ promotion_code: promotionCodeId }] } + : { allow_promotion_codes: trial ? undefined : true }), payment_method_collection: trial ? WEB_TRIAL_CHECKOUT_FIELDS.payment_method_collection : undefined, @@ -394,6 +420,12 @@ const createCheckoutSessionInput = z.object({ trial: z.boolean().default(false), source: checkoutSourceSchema.default("unknown"), returnTo: z.string().optional(), + code: z + .string() + .trim() + .max(64) + .optional() + .transform((value) => (value ? value : undefined)), }); export const createCheckoutSession = createServerFn({ method: "POST" }) @@ -435,6 +467,24 @@ export const createCheckoutSession = createServerFn({ method: "POST" }) try { const stripe = getStripeClient(); + const checkoutCode = + !data.trial && data.code ? data.code.trim() : undefined; + let ycPromotion: Awaited< + ReturnType + > = null; + + if (checkoutCode) { + if (!isYcPromotionCode(checkoutCode)) { + return { url: getAccountYcPerkUrl(data.scheme, "invalid") }; + } + ycPromotion = await findYcPromotionCodeByCustomerCode( + stripe, + normalizeYcPromotionCode(checkoutCode), + ); + if (!ycPromotion) { + return { url: getAccountYcPerkUrl(data.scheme, "invalid") }; + } + } const stripeCustomerId = await getStripeCustomerIdForUser( supabase, @@ -452,17 +502,35 @@ export const createCheckoutSession = createServerFn({ method: "POST" }) if (reservationId) { await releaseTrialReservation(user.id, reservationId); } - const returnUrl = data.scheme - ? `${getBillingReturnUrl(data.scheme)}&source=${data.source}` - : toAbsoluteInternalReturnUrl(getRequestAppOrigin(), returnTo); - const portalSession = await stripe.billingPortal.sessions.create({ - customer: stripeCustomerId, - return_url: returnUrl, - ...(activeSubscription.status === "trialing" - ? { flow_data: paymentMethodUpdateFlow(returnUrl) } - : {}), - }); - return { url: portalSession.url }; + + if (ycPromotion) { + const result = await applyYcPromotionToCustomer({ + stripe, + customerId: stripeCustomerId, + promotion: ycPromotion, + }); + if (result.status === "claimed") { + return { url: getAccountYcPerkUrl(data.scheme, "claimed") }; + } + if ( + result.status === "applied" || + result.status === "already_applied" + ) { + return { url: getAccountYcPerkUrl(data.scheme, "applied") }; + } + } else { + const returnUrl = data.scheme + ? `${getBillingReturnUrl(data.scheme)}&source=${data.source}` + : toAbsoluteInternalReturnUrl(getRequestAppOrigin(), returnTo); + const portalSession = await stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: returnUrl, + ...(activeSubscription.status === "trialing" + ? { flow_data: paymentMethodUpdateFlow(returnUrl) } + : {}), + }); + return { url: portalSession.url }; + } } } @@ -476,6 +544,7 @@ export const createCheckoutSession = createServerFn({ method: "POST" }) trialDays, source: data.source, returnTo, + promotionCodeId: ycPromotion?.id, }); } catch (error) { if (reservationId && !(error instanceof TrialCheckoutCreationError)) { @@ -822,16 +891,7 @@ export const syncAfterSuccess = createServerFn({ method: "POST" }).handler( return { status: "none" }; } - const subscriptions = await stripe.subscriptions.list({ - customer: stripeCustomerId, - status: "all", - }); - - // Prioritize active subscriptions over trialing ones - // This ensures paid users see "active" status even if they had a previous trial - const subscription = - subscriptions.data.find((sub) => sub.status === "active") || - subscriptions.data.find((sub) => sub.status === "trialing"); + const subscription = await getCurrentSubscription(stripe, stripeCustomerId); if (!subscription) { return { status: "none" }; @@ -842,6 +902,55 @@ export const syncAfterSuccess = createServerFn({ method: "POST" }).handler( status: subscription.status, priceId: subscription.items.data[0]?.price.id ?? null, cancelAtPeriodEnd: subscription.cancel_at_period_end, + currentPeriodEnd: getSubscriptionAccessEnd(subscription), + }; + }, +); + +export const getAccountSubscription = createServerFn({ method: "GET" }).handler( + async () => { + const supabase = getSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user?.id) { + throw new Error("Unauthorized"); + } + + const stripe = getStripeClient(); + const stripeCustomerId = await getStripeCustomerIdForUser( + supabase, + stripe, + user, + ); + + if (!stripeCustomerId) { + return { + cancelAtPeriodEnd: false, + currentPeriodEnd: null, + hasYcPerk: false, + }; + } + + const subscription = await getCurrentSubscription( + stripe, + stripeCustomerId, + { expandDiscounts: true }, + ); + + if (!subscription) { + return { + cancelAtPeriodEnd: false, + currentPeriodEnd: null, + hasYcPerk: false, + }; + } + + return { + cancelAtPeriodEnd: subscription.cancel_at_period_end, + currentPeriodEnd: getSubscriptionAccessEnd(subscription), + hasYcPerk: subscriptionHasYcPerk(subscription), }; }, ); diff --git a/apps/web/src/functions/yc-perk.ts b/apps/web/src/functions/yc-perk.ts index 94414f19bb..cb9daa75bd 100644 --- a/apps/web/src/functions/yc-perk.ts +++ b/apps/web/src/functions/yc-perk.ts @@ -2,14 +2,25 @@ import { createServerFn } from "@tanstack/react-start"; import { createHash } from "node:crypto"; import { env, requireEnv } from "@/env"; +import { getStripeCustomerIdForUser } from "@/functions/billing"; import { getStripeClient } from "@/functions/stripe"; -import { getSupabaseAdminClient } from "@/functions/supabase"; +import { + getSupabaseAdminClient, + getSupabaseServerClient, +} from "@/functions/supabase"; import { sendLoopsEvent, sendLoopsTransactional } from "@/lib/loops"; import { normalizeYcVerificationUrl, + parseYcPerkApplyValue, verifyYcFounder, + ycPerkApplyInputSchema, ycPerkRequestSchema, } from "@/lib/yc-perk"; +import { + applyYcPromotionToCustomer, + findYcPromotionCodeByCustomerCode, + isYcPromotionAvailable, +} from "@/lib/yc-perk-apply"; import { createYcPromotionCode, getOrCreateYcPromotionCode, @@ -101,3 +112,73 @@ export const submitYcPerkRequest = createServerFn({ method: "POST" }) return { status: "verified" as const }; }); + +export const applyYcPerk = createServerFn({ method: "POST" }) + .inputValidator(ycPerkApplyInputSchema) + .handler(async ({ data }) => { + const parsed = parseYcPerkApplyValue(data.value); + if (parsed.type === "invalid") { + return { status: "invalid_input" as const, message: parsed.message }; + } + + const supabase = getSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user?.id || user.is_anonymous) { + throw new Error("Unauthorized"); + } + + const stripe = getStripeClient(); + let promotion: { + id: string; + code: string; + active: boolean; + max_redemptions: number | null; + times_redeemed: number; + } | null = null; + + if (parsed.type === "verification_url") { + const verification = await verifyYcFounder({ + verificationUrl: parsed.verificationUrl, + }); + if (verification.status === "invalid") { + return verification; + } + + const claimId = getYcPerkClaimId(verification.email); + const promotionCode = await getOrCreateYcPerkClaimCode(claimId); + promotion = await getOrCreateYcPromotionCode({ + stripe, + claimId, + code: promotionCode, + }); + } else { + promotion = await findYcPromotionCodeByCustomerCode(stripe, parsed.code); + if (!promotion) { + return { status: "invalid_code" as const }; + } + } + + const stripeCustomerId = await getStripeCustomerIdForUser( + supabase, + stripe, + user, + ); + if (!stripeCustomerId) { + if (!isYcPromotionAvailable(promotion)) { + return { status: "claimed" as const }; + } + return { status: "needs_checkout" as const, code: promotion.code }; + } + + const result = await applyYcPromotionToCustomer({ + stripe, + customerId: stripeCustomerId, + promotion, + }); + if (result.status === "needs_checkout") { + return { status: "needs_checkout" as const, code: promotion.code }; + } + return result; + }); diff --git a/apps/web/src/lib/account-plan.test.ts b/apps/web/src/lib/account-plan.test.ts new file mode 100644 index 0000000000..b002e90d70 --- /dev/null +++ b/apps/web/src/lib/account-plan.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatAccountPlanDate, + getAccountPlanCopy, + getSubscriptionAccessEnd, +} from "./account-plan.ts"; + +test("prefers cancel_at, then item period end, then subscription period end", () => { + assert.equal( + getSubscriptionAccessEnd({ + cancel_at: 100, + current_period_end: 200, + items: { data: [{ current_period_end: 300 }] }, + }), + 100, + ); + assert.equal( + getSubscriptionAccessEnd({ + cancel_at: null, + items: { + data: [{ current_period_end: 200 }, { current_period_end: 350 }], + }, + }), + 350, + ); + assert.equal(getSubscriptionAccessEnd({ current_period_end: 400 }), 400); + assert.equal(getSubscriptionAccessEnd({}), null); +}); + +test("paid copy acknowledges a scheduled cancellation", () => { + const currentPeriodEnd = new Date("2026-09-17T00:00:00.000Z"); + + assert.deepEqual( + getAccountPlanCopy({ + isTrialing: false, + isPaid: true, + trialDaysRemaining: null, + trialEnd: null, + cancelAtPeriodEnd: true, + currentPeriodEnd, + }), + { + planLabel: "Pro", + planDetail: `Cancels ${formatAccountPlanDate(currentPeriodEnd)}.`, + }, + ); + + assert.deepEqual( + getAccountPlanCopy({ + isTrialing: false, + isPaid: true, + trialDaysRemaining: null, + trialEnd: null, + cancelAtPeriodEnd: true, + currentPeriodEnd: null, + }), + { + planLabel: "Pro", + planDetail: "Cancels at the end of the billing period.", + }, + ); +}); + +test("paid copy names the YC founder year when that perk is on the subscription", () => { + assert.deepEqual( + getAccountPlanCopy({ + isTrialing: false, + isPaid: true, + trialDaysRemaining: null, + trialEnd: null, + cancelAtPeriodEnd: false, + currentPeriodEnd: new Date("2026-09-17T00:00:00.000Z"), + hasYcPerk: true, + }), + { + planLabel: "Pro", + planDetail: "YC founder year is applied.", + }, + ); +}); + +test("paid copy stays supportive when the subscription is not canceling", () => { + assert.deepEqual( + getAccountPlanCopy({ + isTrialing: false, + isPaid: true, + trialDaysRemaining: null, + trialEnd: null, + cancelAtPeriodEnd: false, + currentPeriodEnd: new Date("2026-09-17T00:00:00.000Z"), + }), + { + planLabel: "Pro", + planDetail: "Thanks for supporting Anarlog.", + }, + ); +}); diff --git a/apps/web/src/lib/account-plan.ts b/apps/web/src/lib/account-plan.ts new file mode 100644 index 0000000000..a4c9725d03 --- /dev/null +++ b/apps/web/src/lib/account-plan.ts @@ -0,0 +1,101 @@ +export function getSubscriptionAccessEnd(subscription: { + cancel_at?: number | null; + current_period_end?: number | null; + items?: { data?: Array<{ current_period_end?: number | null }> }; +}): number | null { + if (typeof subscription.cancel_at === "number") { + return subscription.cancel_at; + } + + const itemPeriodEnds = (subscription.items?.data ?? []) + .map((item) => item.current_period_end) + .filter((value): value is number => typeof value === "number"); + + if (itemPeriodEnds.length > 0) { + return Math.max(...itemPeriodEnds); + } + + return typeof subscription.current_period_end === "number" + ? subscription.current_period_end + : null; +} + +export function formatAccountPlanDate(date: Date) { + return date.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }); +} + +export function getAccountPlanCopy({ + isTrialing, + isPaid, + isLite, + isPro, + trialDaysRemaining, + trialEnd, + cancelAtPeriodEnd, + currentPeriodEnd, + hasYcPerk = false, +}: { + isTrialing: boolean; + isPaid: boolean; + isLite?: boolean; + isPro?: boolean; + trialDaysRemaining: number | null; + trialEnd: Date | null; + cancelAtPeriodEnd: boolean; + currentPeriodEnd: Date | null; + hasYcPerk?: boolean; +}): { planLabel: string; planDetail: string } { + const planLabel = isTrialing + ? "Pro trial" + : isPaid + ? isLite && !isPro + ? "Lite" + : "Pro" + : "Free"; + + if (isTrialing) { + return { + planLabel, + planDetail: trialEnd + ? `${trialDaysRemaining} ${ + trialDaysRemaining === 1 ? "day" : "days" + } left · ends ${trialEnd.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + })}.` + : "Your trial is running.", + }; + } + + if (!isPaid) { + return { + planLabel, + planDetail: "On-device basics, free forever.", + }; + } + + if (cancelAtPeriodEnd) { + return { + planLabel, + planDetail: currentPeriodEnd + ? `Cancels ${formatAccountPlanDate(currentPeriodEnd)}.` + : "Cancels at the end of the billing period.", + }; + } + + if (hasYcPerk) { + return { + planLabel, + planDetail: "YC founder year is applied.", + }; + } + + return { + planLabel, + planDetail: "Thanks for supporting Anarlog.", + }; +} diff --git a/apps/web/src/lib/account-tabs.test.ts b/apps/web/src/lib/account-tabs.test.ts new file mode 100644 index 0000000000..12dc329de1 --- /dev/null +++ b/apps/web/src/lib/account-tabs.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + accountTabForSection, + resolveAccountTab, + sectionsForAccountTab, +} from "./account-tabs.ts"; + +test("maps section hashes to the tab that contains them", () => { + assert.equal(accountTabForSection("plan"), "account"); + assert.equal(accountTabForSection("referrals"), "account"); + assert.equal(accountTabForSection("integrations"), "connections"); + assert.equal(accountTabForSection("shares"), "connections"); + assert.equal(accountTabForSection("api-keys"), "developer"); + assert.equal(accountTabForSection("missing"), undefined); +}); + +test("prefers a section hash over the tab search param", () => { + assert.equal( + resolveAccountTab({ tab: "developer", hash: "#referrals" }), + "account", + ); + assert.equal( + resolveAccountTab({ tab: "account", hash: "integrations" }), + "connections", + ); +}); + +test("falls back to the tab param, then Account", () => { + assert.equal(resolveAccountTab({ tab: "connections" }), "connections"); + assert.equal(resolveAccountTab({ tab: "nope", hash: "" }), "account"); + assert.equal(resolveAccountTab({}), "account"); +}); + +test("an empty hash after a tab click does not override the tab param", () => { + assert.equal( + resolveAccountTab({ tab: "connections", hash: "" }), + "connections", + ); + assert.equal(resolveAccountTab({ tab: "developer", hash: "#" }), "developer"); +}); + +test("lists the sections for a tab in page order", () => { + assert.deepEqual( + sectionsForAccountTab("account").map((section) => section.id), + ["profile", "plan", "referrals", "danger"], + ); +}); diff --git a/apps/web/src/lib/account-tabs.ts b/apps/web/src/lib/account-tabs.ts new file mode 100644 index 0000000000..c35cfd0b8a --- /dev/null +++ b/apps/web/src/lib/account-tabs.ts @@ -0,0 +1,99 @@ +export const ACCOUNT_SECTIONS = [ + { id: "profile", label: "Profile" }, + { id: "plan", label: "Your plan" }, + { id: "referrals", label: "Refer friends" }, + { id: "integrations", label: "Integrations" }, + { id: "devices", label: "Synced devices" }, + { id: "shares", label: "Shared notes" }, + { id: "api-keys", label: "Cloud API keys" }, + { id: "session", label: "Session controls" }, + { id: "danger", label: "Danger area" }, +] as const; + +export type AccountSectionId = (typeof ACCOUNT_SECTIONS)[number]["id"]; + +export const ACCOUNT_TABS = [ + { + id: "account", + label: "Account", + sectionIds: ["profile", "plan", "referrals", "danger"], + }, + { + id: "connections", + label: "Connections", + sectionIds: ["integrations", "devices", "shares"], + }, + { + id: "developer", + label: "Developer", + sectionIds: ["api-keys", "session"], + }, +] as const; + +export type AccountTabId = (typeof ACCOUNT_TABS)[number]["id"]; + +export const DEFAULT_ACCOUNT_TAB: AccountTabId = "account"; + +const SECTION_TAB: Record = { + profile: "account", + plan: "account", + referrals: "account", + danger: "account", + integrations: "connections", + devices: "connections", + shares: "connections", + "api-keys": "developer", + session: "developer", +}; + +export function isAccountTabId(value: string): value is AccountTabId { + return ACCOUNT_TABS.some((tab) => tab.id === value); +} + +export function isAccountSectionId(value: string): value is AccountSectionId { + return ACCOUNT_SECTIONS.some((section) => section.id === value); +} + +export function accountTabForSection( + sectionId: string, +): AccountTabId | undefined { + if (!isAccountSectionId(sectionId)) { + return undefined; + } + return SECTION_TAB[sectionId]; +} + +export function resolveAccountTab(input: { + tab?: string | null; + hash?: string | null; +}): AccountTabId { + const hash = input.hash?.replace(/^#/, "").trim(); + if (hash) { + const fromSection = accountTabForSection(hash); + if (fromSection) { + return fromSection; + } + if (isAccountTabId(hash)) { + return hash; + } + } + + if (input.tab && isAccountTabId(input.tab)) { + return input.tab; + } + + return DEFAULT_ACCOUNT_TAB; +} + +export function sectionsForAccountTab(tabId: AccountTabId) { + const tab = ACCOUNT_TABS.find((item) => item.id === tabId); + if (!tab) { + return []; + } + + return tab.sectionIds + .map((sectionId) => + ACCOUNT_SECTIONS.find((section) => section.id === sectionId), + ) + .filter((section) => section != null); +} diff --git a/apps/web/src/lib/checkout-source.test.ts b/apps/web/src/lib/checkout-source.test.ts index 0365c43af6..bce6c47558 100644 --- a/apps/web/src/lib/checkout-source.test.ts +++ b/apps/web/src/lib/checkout-source.test.ts @@ -8,6 +8,7 @@ import { test("keeps mobile checkout attribution through validation", () => { assert.equal(checkoutSourceSchema.parse("mobile"), "mobile"); + assert.equal(checkoutSourceSchema.parse("yc_perk"), "yc_perk"); assert.equal( checkoutSourceSchema.catch("unknown").parse("invalid"), "unknown", diff --git a/apps/web/src/lib/checkout-source.ts b/apps/web/src/lib/checkout-source.ts index a13070fc05..7a0403d2bb 100644 --- a/apps/web/src/lib/checkout-source.ts +++ b/apps/web/src/lib/checkout-source.ts @@ -6,6 +6,7 @@ export const checkoutSourceSchema = z.enum([ "trial_ended", "feature_gate", "mobile", + "yc_perk", "unknown", ]); diff --git a/apps/web/src/lib/integration-connection-label.test.ts b/apps/web/src/lib/integration-connection-label.test.ts new file mode 100644 index 0000000000..a141cdcb63 --- /dev/null +++ b/apps/web/src/lib/integration-connection-label.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + connectionIdentityLabel, + connectionNeedsReconnect, + connectionReconnectError, +} from "./integration-connection-label.ts"; + +test("treats reconnect_required or a stored error as a broken connection", () => { + assert.equal( + connectionNeedsReconnect({ status: "reconnect_required" }), + true, + ); + assert.equal( + connectionNeedsReconnect({ last_error_type: "refresh_failed" }), + true, + ); + assert.equal(connectionNeedsReconnect({ status: "connected" }), false); +}); + +test("prefers the connected account or workspace over Connected copy", () => { + assert.equal( + connectionIdentityLabel({ + account_identity: "john@fastrepl.com", + status: "connected", + }), + "john@fastrepl.com", + ); + assert.equal( + connectionIdentityLabel({ + account_identity: " Fastrepl ", + status: "reconnect_required", + }), + "Fastrepl", + ); + assert.equal( + connectionIdentityLabel({ status: "reconnect_required" }), + "Needs reconnect.", + ); + assert.equal(connectionIdentityLabel({ status: "connected" }), "Connected."); +}); + +test("keeps reconnect errors for tooltips", () => { + assert.equal( + connectionReconnectError({ + last_error_description: "Token refresh failed.", + }), + "Token refresh failed.", + ); + assert.equal(connectionReconnectError({}), "Connection needs attention."); +}); diff --git a/apps/web/src/lib/integration-connection-label.ts b/apps/web/src/lib/integration-connection-label.ts new file mode 100644 index 0000000000..c78cf73c0d --- /dev/null +++ b/apps/web/src/lib/integration-connection-label.ts @@ -0,0 +1,31 @@ +export function connectionNeedsReconnect(connection: { + status?: string | null; + last_error_type?: string | null; +}) { + return ( + connection.status === "reconnect_required" || + Boolean(connection.last_error_type) + ); +} + +export function connectionIdentityLabel(connection: { + account_identity?: string | null; + status?: string | null; + last_error_type?: string | null; +}) { + const identity = connection.account_identity?.trim(); + if (identity) { + return identity; + } + if (connectionNeedsReconnect(connection)) { + return "Needs reconnect."; + } + return "Connected."; +} + +export function connectionReconnectError(connection: { + last_error_description?: string | null; +}) { + const description = connection.last_error_description?.trim(); + return description || "Connection needs attention."; +} diff --git a/apps/web/src/lib/yc-perk-apply.test.ts b/apps/web/src/lib/yc-perk-apply.test.ts new file mode 100644 index 0000000000..f555d9c4bd --- /dev/null +++ b/apps/web/src/lib/yc-perk-apply.test.ts @@ -0,0 +1,203 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type Stripe from "stripe"; + +import { + applyYcPromotionToCustomer, + isYcFounderPromotion, + isYcPromotionAvailable, + pickCurrentSubscription, + subscriptionHasYcPerk, +} from "./yc-perk-apply.ts"; + +const promotion = { + id: "promo_yc", + active: true, + max_redemptions: 1, + times_redeemed: 0, +}; + +test("only accepts promotion codes for the YC founder coupon", () => { + assert.equal( + isYcFounderPromotion({ + promotion: { coupon: "yc-founders-1-year-free" }, + }), + true, + ); + assert.equal( + isYcFounderPromotion({ + promotion: { coupon: { id: "yc-founders-1-year-free" } }, + }), + true, + ); + assert.equal( + isYcFounderPromotion({ promotion: { coupon: "other-coupon" } }), + false, + ); +}); + +test("detects the YC founder coupon on expanded subscription discounts", () => { + assert.equal( + subscriptionHasYcPerk({ + discounts: [{ source: { coupon: "yc-founders-1-year-free" } }], + }), + true, + ); + assert.equal( + subscriptionHasYcPerk({ + discounts: [{ source: { coupon: { id: "yc-founders-1-year-free" } } }], + }), + true, + ); + assert.equal(subscriptionHasYcPerk({ discounts: ["di_123"] }), false); + assert.equal(subscriptionHasYcPerk({ discounts: [] }), false); +}); + +test("treats a fully redeemed promotion as unavailable", () => { + assert.equal(isYcPromotionAvailable(promotion), true); + assert.equal( + isYcPromotionAvailable({ + ...promotion, + times_redeemed: 1, + }), + false, + ); + assert.equal( + isYcPromotionAvailable({ + ...promotion, + active: false, + }), + false, + ); +}); + +test("prefers an active subscription over a trial", () => { + assert.equal( + pickCurrentSubscription([ + { status: "canceled" }, + { status: "trialing" }, + { status: "active" }, + ])?.status, + "active", + ); + assert.equal( + pickCurrentSubscription([{ status: "trialing" }])?.status, + "trialing", + ); + assert.equal(pickCurrentSubscription([{ status: "canceled" }]), null); +}); + +test("applies the promotion to an existing subscription and clears a scheduled cancel", async () => { + const updates: unknown[] = []; + const stripe = { + subscriptions: { + list: async () => ({ + data: [ + { + id: "sub_pro", + status: "active", + cancel_at_period_end: true, + discounts: [], + }, + ], + }), + update: async (id: string, params: unknown) => { + updates.push({ id, params }); + return { id }; + }, + }, + } as unknown as Stripe; + + assert.deepEqual( + await applyYcPromotionToCustomer({ + stripe, + customerId: "cus_pro", + promotion, + }), + { status: "applied", subscriptionId: "sub_pro" }, + ); + assert.deepEqual(updates, [ + { + id: "sub_pro", + params: { + discounts: [{ promotion_code: "promo_yc" }], + cancel_at_period_end: false, + }, + }, + ]); +}); + +test("does not re-redeem when the subscription already has the YC perk", async () => { + let updates = 0; + const stripe = { + subscriptions: { + list: async () => ({ + data: [ + { + id: "sub_pro", + status: "active", + cancel_at_period_end: false, + discounts: [{ source: { coupon: "yc-founders-1-year-free" } }], + }, + ], + }), + update: async () => { + updates += 1; + return { id: "sub_pro" }; + }, + }, + } as unknown as Stripe; + + assert.deepEqual( + await applyYcPromotionToCustomer({ + stripe, + customerId: "cus_pro", + promotion: { ...promotion, times_redeemed: 1, active: false }, + }), + { status: "already_applied", subscriptionId: "sub_pro" }, + ); + assert.equal(updates, 0); +}); + +test("sends customers without a subscription to checkout with the promotion", async () => { + const stripe = { + subscriptions: { + list: async () => ({ data: [] }), + }, + } as unknown as Stripe; + + assert.deepEqual( + await applyYcPromotionToCustomer({ + stripe, + customerId: "cus_free", + promotion, + }), + { status: "needs_checkout", promotionCodeId: "promo_yc" }, + ); +}); + +test("rejects a code that another customer already redeemed", async () => { + const stripe = { + subscriptions: { + list: async () => ({ + data: [ + { + id: "sub_pro", + status: "active", + cancel_at_period_end: false, + discounts: [], + }, + ], + }), + }, + } as unknown as Stripe; + + assert.deepEqual( + await applyYcPromotionToCustomer({ + stripe, + customerId: "cus_pro", + promotion: { ...promotion, times_redeemed: 1, active: false }, + }), + { status: "claimed" }, + ); +}); diff --git a/apps/web/src/lib/yc-perk-apply.ts b/apps/web/src/lib/yc-perk-apply.ts new file mode 100644 index 0000000000..9d82afd8bd --- /dev/null +++ b/apps/web/src/lib/yc-perk-apply.ts @@ -0,0 +1,125 @@ +import type Stripe from "stripe"; + +import { YC_FOUNDER_COUPON_ID } from "./yc-perk-promotion.ts"; + +function couponId( + coupon: string | { id?: string } | null | undefined, +): string | null { + if (typeof coupon === "string") { + return coupon || null; + } + return coupon?.id ?? null; +} + +export function isYcFounderPromotion(promotion: { + promotion?: { coupon?: string | { id?: string } | null }; +}) { + return couponId(promotion.promotion?.coupon) === YC_FOUNDER_COUPON_ID; +} + +export function subscriptionHasYcPerk(subscription: { + discounts?: Array< + | string + | { + source?: { coupon?: string | { id?: string } | null }; + } + >; +}): boolean { + return (subscription.discounts ?? []).some((discount) => { + if (typeof discount === "string") { + return false; + } + return couponId(discount.source?.coupon) === YC_FOUNDER_COUPON_ID; + }); +} + +export function isYcPromotionAvailable(promotion: { + active: boolean; + max_redemptions: number | null; + times_redeemed: number; +}) { + return ( + promotion.active && + (promotion.max_redemptions === null || + promotion.times_redeemed < promotion.max_redemptions) + ); +} + +export function pickCurrentSubscription( + subscriptions: T[], +): T | null { + return ( + subscriptions.find((subscription) => subscription.status === "active") || + subscriptions.find((subscription) => subscription.status === "trialing") || + null + ); +} + +export async function findPromotionCodeByCustomerCode( + stripe: Stripe, + code: string, +) { + const listed = await stripe.promotionCodes.list({ + code, + limit: 1, + }); + return listed.data[0] ?? null; +} + +export async function findYcPromotionCodeByCustomerCode( + stripe: Stripe, + code: string, +) { + const promotion = await findPromotionCodeByCustomerCode(stripe, code); + if (!promotion || !isYcFounderPromotion(promotion)) { + return null; + } + return promotion; +} + +export async function applyYcPromotionToCustomer({ + stripe, + customerId, + promotion, +}: { + stripe: Stripe; + customerId: string; + promotion: Pick< + Stripe.PromotionCode, + "id" | "active" | "max_redemptions" | "times_redeemed" + >; +}): Promise< + | { status: "applied"; subscriptionId: string } + | { status: "already_applied"; subscriptionId: string } + | { status: "needs_checkout"; promotionCodeId: string } + | { status: "claimed" } +> { + const subscriptions = await stripe.subscriptions.list({ + customer: customerId, + status: "all", + limit: 10, + expand: ["data.discounts"], + }); + const subscription = pickCurrentSubscription(subscriptions.data); + + if (subscription && subscriptionHasYcPerk(subscription)) { + return { status: "already_applied", subscriptionId: subscription.id }; + } + + if (!isYcPromotionAvailable(promotion)) { + return { status: "claimed" }; + } + + if (!subscription) { + return { status: "needs_checkout", promotionCodeId: promotion.id }; + } + + await stripe.subscriptions.update(subscription.id, { + discounts: [{ promotion_code: promotion.id }], + ...(subscription.cancel_at_period_end + ? { cancel_at_period_end: false } + : {}), + }); + + return { status: "applied", subscriptionId: subscription.id }; +} diff --git a/apps/web/src/lib/yc-perk-promotion.test.ts b/apps/web/src/lib/yc-perk-promotion.test.ts index 33feb9f161..6422705fcf 100644 --- a/apps/web/src/lib/yc-perk-promotion.test.ts +++ b/apps/web/src/lib/yc-perk-promotion.test.ts @@ -29,6 +29,7 @@ test("reuses an existing promotion code", async () => { list: async () => ({ data: [ { + id: "promo_existing", active: true, code: "YC-EXISTING", max_redemptions: 1, @@ -39,14 +40,21 @@ test("reuses an existing promotion code", async () => { }), create: async () => { creates += 1; - return { code: "YC-NEW" }; + return { id: "promo_new", code: "YC-NEW" }; }, }, } as unknown as Stripe; assert.deepEqual( await getOrCreateYcPromotionCode({ stripe, claimId, code }), - { status: "available", code: "YC-EXISTING" }, + { + status: "available", + id: "promo_existing", + code: "YC-EXISTING", + active: true, + max_redemptions: 1, + times_redeemed: 0, + }, ); assert.equal(creates, 0); }); @@ -57,6 +65,7 @@ test("reports an existing redeemed promotion code as claimed", async () => { list: async () => ({ data: [ { + id: "promo_claimed", active: false, code: "YC-CLAIMED", max_redemptions: 1, @@ -70,7 +79,14 @@ test("reports an existing redeemed promotion code as claimed", async () => { assert.deepEqual( await getOrCreateYcPromotionCode({ stripe, claimId, code }), - { status: "claimed" }, + { + status: "claimed", + id: "promo_claimed", + code: "YC-CLAIMED", + active: false, + max_redemptions: 1, + times_redeemed: 1, + }, ); }); @@ -81,14 +97,27 @@ test("creates a single-use promotion code for the one-year YC coupon", async () list: async () => ({ data: [] }), create: async (params: unknown, options: unknown) => { calls.push({ params, options }); - return { code }; + return { + id: "promo_created", + code, + active: true, + max_redemptions: 1, + times_redeemed: 0, + }; }, }, } as unknown as Stripe; assert.deepEqual( await getOrCreateYcPromotionCode({ stripe, claimId, code }), - { status: "available", code }, + { + status: "available", + id: "promo_created", + code, + active: true, + max_redemptions: 1, + times_redeemed: 0, + }, ); assert.deepEqual(calls, [ { diff --git a/apps/web/src/lib/yc-perk-promotion.ts b/apps/web/src/lib/yc-perk-promotion.ts index adc432984b..96f2d0da23 100644 --- a/apps/web/src/lib/yc-perk-promotion.ts +++ b/apps/web/src/lib/yc-perk-promotion.ts @@ -22,12 +22,23 @@ export async function getOrCreateYcPromotionCode({ }) { const findPromotionCode = async () => (await stripe.promotionCodes.list({ code, limit: 1 })).data[0]; + const toPromotionResult = ( + promotion: Stripe.PromotionCode, + status: "available" | "claimed", + ) => ({ + status, + id: promotion.id, + code: promotion.code, + active: promotion.active, + max_redemptions: promotion.max_redemptions, + times_redeemed: promotion.times_redeemed, + }); const getExistingPromotion = (promotion: Stripe.PromotionCode) => !promotion.active || (promotion.max_redemptions !== null && promotion.times_redeemed >= promotion.max_redemptions) - ? ({ status: "claimed" } as const) - : ({ status: "available", code: promotion.code } as const); + ? toPromotionResult(promotion, "claimed") + : toPromotionResult(promotion, "available"); const existingPromotion = await findPromotionCode(); if (existingPromotion) { @@ -50,7 +61,7 @@ export async function getOrCreateYcPromotionCode({ }, { idempotencyKey: `yc-perk-promotion:${claimId}` }, ); - return { status: "available", code: promotionCode.code } as const; + return toPromotionResult(promotionCode, "available"); } catch (error) { const concurrentPromotion = await findPromotionCode(); if (concurrentPromotion) { diff --git a/apps/web/src/lib/yc-perk.test.ts b/apps/web/src/lib/yc-perk.test.ts index a70705a3e6..cb3ba7e776 100644 --- a/apps/web/src/lib/yc-perk.test.ts +++ b/apps/web/src/lib/yc-perk.test.ts @@ -5,6 +5,8 @@ import { getYcVerificationApiUrl, isYcVerificationUrl, normalizeYcVerificationUrl, + parseYcPerkApplyValue, + validateYcPerkApplyValue, validateYcVerificationUrl, verifyYcFounder, ycPerkRequestSchema, @@ -70,6 +72,30 @@ test("normalizes founder verification links", () => { ); }); +test("parses YC verification links and promotion codes for account apply", () => { + assert.deepEqual(parseYcPerkApplyValue(" YC-0123456789abcdef01234567 "), { + type: "promotion_code", + code: "YC-0123456789ABCDEF01234567", + }); + assert.deepEqual( + parseYcPerkApplyValue( + " https://ycombinator.com/verify/founder-token/?source=deal ", + ), + { + type: "verification_url", + verificationUrl: "https://www.ycombinator.com/verify/founder-token", + }, + ); + assert.equal( + validateYcPerkApplyValue("https://example.com/verify/founder-token"), + "Use your ycombinator.com/verify link", + ); + assert.equal( + validateYcPerkApplyValue("SAVE20"), + "Paste your YC verification link or YC- code", + ); +}); + test("returns field-level validation messages", () => { assert.equal( validateYcVerificationUrl("https://example.com/verify/founder-token"), diff --git a/apps/web/src/lib/yc-perk.ts b/apps/web/src/lib/yc-perk.ts index 4306cccdd7..9e0a194be8 100644 --- a/apps/web/src/lib/yc-perk.ts +++ b/apps/web/src/lib/yc-perk.ts @@ -34,6 +34,64 @@ export type YcFounderVerificationResult = reason: "not_verified" | "email_missing"; }; +export function isYcPromotionCode(value: string) { + return /^YC-[A-F0-9]{24}$/i.test(value.trim()); +} + +export function normalizeYcPromotionCode(value: string) { + return value.trim().toUpperCase(); +} + +export type YcPerkApplyValue = + | { type: "verification_url"; verificationUrl: string } + | { type: "promotion_code"; code: string } + | { type: "invalid"; message: string }; + +export const ycPerkApplyInputSchema = z.object({ + value: z + .string() + .trim() + .min(1, "Paste your YC verification link or YC- code") + .max(2_048), +}); + +export function parseYcPerkApplyValue(value: string): YcPerkApplyValue { + const trimmed = value.trim(); + if (!trimmed) { + return { + type: "invalid", + message: "Paste your YC verification link or YC- code", + }; + } + + if (isYcPromotionCode(trimmed)) { + return { type: "promotion_code", code: normalizeYcPromotionCode(trimmed) }; + } + + if (/^https?:\/\//i.test(trimmed) || trimmed.includes("ycombinator.com")) { + if (!isYcVerificationUrl(trimmed)) { + return { + type: "invalid", + message: "Use your ycombinator.com/verify link", + }; + } + return { + type: "verification_url", + verificationUrl: normalizeYcVerificationUrl(trimmed), + }; + } + + return { + type: "invalid", + message: "Paste your YC verification link or YC- code", + }; +} + +export function validateYcPerkApplyValue(value: string) { + const parsed = parseYcPerkApplyValue(value); + return parsed.type === "invalid" ? parsed.message : undefined; +} + export function isYcVerificationUrl(value: string) { try { const url = new URL(value.trim()); diff --git a/apps/web/src/routes/_view/app/-account-danger.tsx b/apps/web/src/routes/_view/app/-account-danger.tsx index 1fb2498612..7921536cb0 100644 --- a/apps/web/src/routes/_view/app/-account-danger.tsx +++ b/apps/web/src/routes/_view/app/-account-danger.tsx @@ -7,10 +7,7 @@ import { cn } from "@anlg/utils"; import { deleteAccount } from "@/functions/billing"; import { captureOperationalError } from "@/lib/error-reporting"; -import { - accountCardClassName, - accountPillDangerClassName, -} from "./-account-ui"; +import { accountPillDangerClassName } from "./-account-ui"; export function DangerAreaSection() { const navigate = useNavigate(); @@ -29,61 +26,63 @@ export function DangerAreaSection() { }); return ( -
-
-

Delete account

-
-

- Anarlog is a local-first app. Your notes, transcripts, and meeting - data stay on your device. Deleting your account only removes - cloud-stored data. -

+
+

Delete account

+

+ Anarlog is a local-first app. Your notes, transcripts, and meeting data + stay on your device. Deleting your account only removes cloud-stored + data. +

- {showDeleteConfirm ? ( -
-

- This permanently deletes your account and cloud data. -

+ {showDeleteConfirm ? ( +
+

+ This permanently deletes your account and cloud data. +

- {deleteAccountMutation.isError && ( -

- {deleteAccountMutation.error?.message || - "Failed to delete account"} -

- )} + {deleteAccountMutation.isError && ( +

+ {deleteAccountMutation.error?.message || + "Failed to delete account"} +

+ )} -
- - -
-
- ) : ( +
- )} + +
-
+ ) : ( + + )}
); } diff --git a/apps/web/src/routes/_view/app/-account-integrations.tsx b/apps/web/src/routes/_view/app/-account-integrations.tsx index aa666cd843..268081502b 100644 --- a/apps/web/src/routes/_view/app/-account-integrations.tsx +++ b/apps/web/src/routes/_view/app/-account-integrations.tsx @@ -6,8 +6,20 @@ import type { ReactNode } from "react"; import { listConnections } from "@anlg/api-client"; import { OutlookIcon } from "@anlg/ui/components/icons/outlook"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@anlg/ui/components/ui/tooltip"; import { cn } from "@anlg/utils"; +import { + connectionIdentityLabel, + connectionNeedsReconnect, + connectionReconnectError, +} from "@/lib/integration-connection-label"; + import { getAuthorizedApiClient } from "./-account-api"; import { useAccountSession } from "./-account-session"; import { @@ -89,73 +101,81 @@ export function IntegrationsSection() { : "Integrations come with a paid plan and connect from the desktop app."}

) : ( -
    - {connections.map((connection) => { - const hasError = !!connection.last_error_type; - return ( -
  • -
    - -
    -

    - {INTEGRATION_NAMES[connection.integration_id] ?? - connection.integration_id} -

    -

    - {hasError - ? connection.last_error_description || - "Connection needs attention." - : "Connected."} -

    + +
      + {connections.map((connection) => { + const name = + INTEGRATION_NAMES[connection.integration_id] ?? + connection.integration_id; + const needsReconnect = connectionNeedsReconnect(connection); + const reconnectError = connectionReconnectError(connection); + + return ( +
    • +
      + +
      +

      + {name} +

      +

      + {connectionIdentityLabel(connection)} +

      +
      -
    -
    - {hasError && ( +
    + {needsReconnect && ( + + + + Reconnect + + + + {reconnectError} + + + )} - Reconnect +
    -
  • - ); - })} -
+
+ + ); + })} + + )}
); diff --git a/apps/web/src/routes/_view/app/-account-nav.tsx b/apps/web/src/routes/_view/app/-account-nav.tsx new file mode 100644 index 0000000000..b7b8ae0df1 --- /dev/null +++ b/apps/web/src/routes/_view/app/-account-nav.tsx @@ -0,0 +1,41 @@ +import { cn } from "@anlg/utils"; + +import { ACCOUNT_TABS, type AccountTabId } from "@/lib/account-tabs"; + +export function AccountTabs({ + activeId, + onSelect, +}: { + activeId: AccountTabId; + onSelect: (tabId: AccountTabId) => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/routes/_view/app/-account-plan.tsx b/apps/web/src/routes/_view/app/-account-plan.tsx index 6b6761fbf6..96bb5034ff 100644 --- a/apps/web/src/routes/_view/app/-account-plan.tsx +++ b/apps/web/src/routes/_view/app/-account-plan.tsx @@ -1,4 +1,14 @@ -import { Link } from "@tanstack/react-router"; +import { useForm } from "@tanstack/react-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useNavigate } from "@tanstack/react-router"; + +import { cn } from "@anlg/utils"; + +import { authInputClassName } from "@/components/auth-shell"; +import { getAccountSubscription } from "@/functions/billing"; +import { applyYcPerk } from "@/functions/yc-perk"; +import { getAccountPlanCopy } from "@/lib/account-plan"; +import { validateYcPerkApplyValue } from "@/lib/yc-perk"; import { useAccountSession } from "./-account-session"; import { @@ -7,35 +17,63 @@ import { accountPillSecondaryClassName, } from "./-account-ui"; -export function PlanSection() { +export const accountSubscriptionQueryKey = ["account-subscription"]; + +const ycPerkApplyErrorMessages = { + claimed: "This perk has already been claimed.", + invalid: "This YC code is not valid.", + not_verified: "This YC link is no longer active.", + email_missing: "Update your YC link to include your email.", +}; + +export function PlanSection({ + perk, +}: { + perk?: "applied" | "claimed" | "invalid"; +}) { const { data, isPending } = useAccountSession(); const billing = data?.billing; + const subscriptionQuery = useQuery({ + queryKey: accountSubscriptionQueryKey, + enabled: + typeof window !== "undefined" && + (billing?.isPaid === true || billing?.isTrialing === true), + queryFn: () => getAccountSubscription(), + }); + + const cancelAtPeriodEnd = + subscriptionQuery.data?.cancelAtPeriodEnd ?? + billing?.cancelAtPeriodEnd ?? + false; + const currentPeriodEnd = + subscriptionQuery.data?.currentPeriodEnd != null + ? new Date(subscriptionQuery.data.currentPeriodEnd * 1000) + : (billing?.currentPeriodEnd ?? null); + const hasYcPerk = + subscriptionQuery.data?.hasYcPerk === true || perk === "applied"; - const planLabel = billing?.isTrialing - ? "Pro trial" - : billing?.isPaid - ? billing.isLite && !billing.isPro - ? "Lite" - : "Pro" - : "Free"; + const { planLabel, planDetail } = getAccountPlanCopy({ + isTrialing: billing?.isTrialing === true, + isPaid: billing?.isPaid === true, + isLite: billing?.isLite, + isPro: billing?.isPro, + trialDaysRemaining: billing?.trialDaysRemaining ?? null, + trialEnd: billing?.trialEnd ?? null, + cancelAtPeriodEnd, + currentPeriodEnd, + hasYcPerk, + }); - const planDetail = billing?.isTrialing - ? billing.trialEnd - ? `${billing.trialDaysRemaining} ${ - billing.trialDaysRemaining === 1 ? "day" : "days" - } left · ends ${billing.trialEnd.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - })}.` - : "Your trial is running." - : billing?.isPaid - ? "Thanks for supporting Anarlog." - : "On-device basics, free forever."; + const isCheckingPlan = + isPending || + (billing?.isPaid === true && + billing.isTrialing !== true && + subscriptionQuery.isPending); return (
- {isPending ? ( + {isCheckingPlan ? (

Checking your plan...

@@ -72,6 +110,170 @@ export function PlanSection() { )}
+ {!isCheckingPlan && !hasYcPerk ? : null}
); } + +function YcPerkApplyForm({ + perk, +}: { + perk?: "applied" | "claimed" | "invalid"; +}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const applyMutation = useMutation({ + mutationFn: (value: string) => applyYcPerk({ data: { value } }), + onSuccess: (result) => { + if (result.status === "needs_checkout" && result.code) { + void navigate({ + to: "/app/checkout/", + search: { + period: "monthly", + trial: "false", + source: "yc_perk", + code: result.code, + }, + }); + return; + } + if (result.status === "applied" || result.status === "already_applied") { + void queryClient.invalidateQueries({ + queryKey: accountSubscriptionQueryKey, + }); + } + }, + }); + const form = useForm({ + defaultValues: { value: "" }, + onSubmit: ({ value }) => applyMutation.mutate(value.value), + }); + const applied = + applyMutation.data?.status === "applied" || + applyMutation.data?.status === "already_applied"; + const errorMessage = applied + ? undefined + : applyMutation.data?.status === "claimed" + ? ycPerkApplyErrorMessages.claimed + : applyMutation.data?.status === "invalid" + ? ycPerkApplyErrorMessages[applyMutation.data.reason] + : applyMutation.data?.status === "invalid_code" + ? ycPerkApplyErrorMessages.invalid + : applyMutation.data?.status === "invalid_input" + ? applyMutation.data.message + : applyMutation.isError + ? "We couldn’t apply this. Try again." + : perk === "claimed" + ? ycPerkApplyErrorMessages.claimed + : perk === "invalid" + ? ycPerkApplyErrorMessages.invalid + : undefined; + + if (applied) { + return ( +
+

+ YC founder year is applied. +

+
+ ); + } + + return ( +
+

+ YC founder? Paste your verification link or Pro code. +

+
{ + event.preventDefault(); + event.stopPropagation(); + void form.handleSubmit(); + }} + > + validateYcPerkApplyValue(value), + onBlur: ({ value }) => validateYcPerkApplyValue(value), + onSubmit: ({ value }) => validateYcPerkApplyValue(value), + }} + > + {(field) => ( +
+ + field.handleChange(event.target.value)} + className={cn([ + authInputClassName, + "h-9 rounded-full px-4 text-sm", + field.state.meta.errors.length > 0 + ? "border-red-500" + : undefined, + ])} + aria-invalid={field.state.meta.errors.length > 0} + /> + +
+ )} +
+ +
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +

+ Need a verification link?{" "} + + Get one from YC + + {" · "} + + Learn more + +

+
+ ); +} + +function FieldError({ errors }: { errors: Array }) { + const firstError = errors[0]; + const message = + typeof firstError === "string" + ? firstError + : firstError && typeof firstError === "object" && "message" in firstError + ? String(firstError.message) + : undefined; + + return message ? ( +

+ {message} +

+ ) : null; +} diff --git a/apps/web/src/routes/_view/app/-account-shares.tsx b/apps/web/src/routes/_view/app/-account-shares.tsx index 81afd8093b..5d96104425 100644 --- a/apps/web/src/routes/_view/app/-account-shares.tsx +++ b/apps/web/src/routes/_view/app/-account-shares.tsx @@ -1,9 +1,20 @@ +import { DotsThree } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@anlg/ui/components/ui/dropdown-menu"; +import { cn } from "@anlg/utils"; + import { deleteMyShare, + deleteMyShares, listMyManagedShares, restrictMyShare, } from "@/functions/account-shares"; @@ -25,9 +36,7 @@ const sharesQueryKey = ["account-managed-shares"]; export function SharedNotesSection() { const queryClient = useQueryClient(); - const [confirmingShareId, setConfirmingShareId] = useState( - null, - ); + const [confirmingAll, setConfirmingAll] = useState(false); const sharesQuery = useQuery({ queryKey: sharesQueryKey, @@ -63,12 +72,28 @@ export function SharedNotesSection() { } }, onSuccess: () => { - setConfirmingShareId(null); + queryClient.invalidateQueries({ queryKey: sharesQueryKey }); + }, + }); + + const stopSharingAll = useMutation({ + mutationFn: async (shareIds: string[]) => { + const result = await deleteMyShares({ data: { shareIds } }); + if (!result.success) { + throw new Error(result.message); + } + }, + onSuccess: () => { + setConfirmingAll(false); + }, + onSettled: () => { queryClient.invalidateQueries({ queryKey: sharesQueryKey }); }, }); const shares = sharesQuery.data ?? []; + const actionsDisabled = + restrict.isPending || stopSharing.isPending || stopSharingAll.isPending; return (
@@ -86,66 +111,65 @@ export function SharedNotesSection() { show up here.

) : ( -
    - {shares.map((share) => ( -
  • +
    + - )} - -
    -
  • - ))} -
+ {stopSharingAll.isPending + ? "Stopping..." + : confirmingAll + ? "You sure?" + : "Stop sharing all"} + +
+
    + {shares.map((share) => ( +
  • +
    +

    + {share.title || "Untitled note"} +

    +

    + {SCOPE_LABELS[share.scope]} · updated{" "} + {new Date(share.updatedAt).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + })} +

    +
    + setConfirmingAll(false)} + onRestrict={() => restrict.mutate(share.shareId)} + onStopSharing={() => stopSharing.mutate(share.shareId)} + /> +
  • + ))} +
+ )} {restrict.isError && (

@@ -157,6 +181,82 @@ export function SharedNotesSection() { {stopSharing.error?.message || "Failed to stop sharing this note"}

)} + {stopSharingAll.isError && ( +

+ {stopSharingAll.error?.message || "Failed to stop sharing your notes"} +

+ )} ); } + +function ShareRowMenu({ + shareId, + title, + canRestrict, + disabled, + restricting, + stopping, + onOpenChange, + onRestrict, + onStopSharing, +}: { + shareId: string; + title: string; + canRestrict: boolean; + disabled: boolean; + restricting: boolean; + stopping: boolean; + onOpenChange: () => void; + onRestrict: () => void; + onStopSharing: () => void; +}) { + return ( + { + if (open) { + onOpenChange(); + } + }} + > + + + + + + + Open + + + {canRestrict && ( + + {restricting ? "Restricting..." : "Restrict"} + + )} + + + {stopping ? "Stopping..." : "Stop sharing"} + + + + ); +} diff --git a/apps/web/src/routes/_view/app/account.tsx b/apps/web/src/routes/_view/app/account.tsx index 6bfa9abca1..c67055510e 100644 --- a/apps/web/src/routes/_view/app/account.tsx +++ b/apps/web/src/routes/_view/app/account.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { jwtDecode } from "jwt-decode"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { z } from "zod"; import { deriveBillingInfo, type SupabaseJwtPayload } from "@anlg/supabase"; @@ -10,6 +10,14 @@ import { AnarlogLogo } from "@/components/anarlog-logo"; import { desktopSchemeSchema } from "@/functions/desktop-flow"; import { getSupabaseBrowserClient } from "@/functions/supabase"; import { useAnalytics } from "@/hooks/use-posthog"; +import { + ACCOUNT_SECTIONS, + type AccountSectionId, + type AccountTabId, + accountTabForSection, + resolveAccountTab, + sectionsForAccountTab, +} from "@/lib/account-tabs"; import { checkoutSourceSchema } from "@/lib/checkout-source"; import { AccountAccessSection } from "./-account-access"; @@ -17,6 +25,7 @@ import { ApiKeysSection } from "./-account-api-keys"; import { DangerAreaSection } from "./-account-danger"; import { DevicesSection } from "./-account-devices"; import { IntegrationsSection } from "./-account-integrations"; +import { AccountTabs } from "./-account-nav"; import { PlanSection } from "./-account-plan"; import { ProfileInfoSection } from "./-account-profile-info"; import { ReferralSection } from "./-account-referrals"; @@ -32,6 +41,8 @@ const validateSearch = z checkout_type: z.enum(["trial", "paid"]), source: checkoutSourceSchema, referral: z.enum(["ineligible"]), + perk: z.enum(["applied", "claimed", "invalid"]), + tab: z.enum(["account", "connections", "developer"]), }) .partial(); @@ -44,8 +55,11 @@ export const Route = createFileRoute("/_view/app/account")({ function Component() { const { user } = Route.useLoaderData(); const search = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); const { identify: identifyPosthog, track } = useAnalytics(); const queryClient = useQueryClient(); + const [hash, setHash] = useState(""); + const activeTab = resolveAccountTab({ tab: search.tab, hash }); useEffect(() => { if (!search.success && search.trial !== "started") { @@ -100,6 +114,35 @@ function Component() { track, ]); + useEffect(() => { + const syncHash = () => setHash(window.location.hash); + syncHash(); + window.addEventListener("hashchange", syncHash); + return () => window.removeEventListener("hashchange", syncHash); + }, []); + + useEffect(() => { + const sectionId = hash.replace(/^#/, ""); + if (!sectionId || accountTabForSection(sectionId) !== activeTab) { + return; + } + + document.getElementById(sectionId)?.scrollIntoView({ block: "start" }); + }, [activeTab, hash]); + + const selectTab = (tabId: AccountTabId) => { + setHash(""); + void navigate({ + search: (prev) => ({ + ...prev, + tab: tabId === "account" ? undefined : tabId, + }), + // Empty string is treated as omitted and would keep the current hash. + hash: () => "", + replace: true, + }); + }; + return (
@@ -119,89 +162,82 @@ function Component() { -
-
-

- Profile -

-
- -
-
- -
-

- Refer friends -

-
- -
-
- -
-

- Your plan -

-
- -
-
- -
-

- Integrations -

-
- -
-
- -
-

- Synced devices -

-
- -
-
- -
-

- Shared notes -

-
- -
-
- -
-

- Cloud API keys -

-
- -
-
- -
-

- Session controls -

-
- -
-
- -
-

- Danger area -

-
- -
-
+
+
+ +
+ +
+ {sectionsForAccountTab(activeTab).map((section) => ( + + + + ))} +
); } + +function AccountSection({ + id, + children, +}: { + id: AccountSectionId; + children: React.ReactNode; +}) { + const title = ACCOUNT_SECTIONS.find((section) => section.id === id)?.label; + + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function AccountSectionBody({ + id, + email, + perk, + referralIneligible, +}: { + id: AccountSectionId; + email?: string; + perk?: "applied" | "claimed" | "invalid"; + referralIneligible: boolean; +}) { + switch (id) { + case "profile": + return ; + case "plan": + return ; + case "referrals": + return ; + case "integrations": + return ; + case "devices": + return ; + case "shares": + return ; + case "api-keys": + return ; + case "session": + return ; + case "danger": + return ; + } +} diff --git a/apps/web/src/routes/_view/app/checkout.tsx b/apps/web/src/routes/_view/app/checkout.tsx index a4d592ba2a..b912625a23 100644 --- a/apps/web/src/routes/_view/app/checkout.tsx +++ b/apps/web/src/routes/_view/app/checkout.tsx @@ -20,6 +20,12 @@ const validateSearch = z.object({ .transform((value) => value === "true"), source: checkoutSourceSchema.catch("unknown"), return_to: z.string().optional(), + code: z + .string() + .trim() + .max(64) + .optional() + .transform((value) => (value ? value : undefined)), }); export const Route = createFileRoute("/_view/app/checkout")({ @@ -36,6 +42,7 @@ export const Route = createFileRoute("/_view/app/checkout")({ trial: search.trial, source: search.source, returnTo, + code: search.code, }, })); } catch (e) { diff --git a/apps/web/src/routes/_view/callback/integration.tsx b/apps/web/src/routes/_view/callback/integration.tsx index 487e435fc6..2b555fd331 100644 --- a/apps/web/src/routes/_view/callback/integration.tsx +++ b/apps/web/src/routes/_view/callback/integration.tsx @@ -95,7 +95,10 @@ function Component() { void queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === "integration-status", }); - void navigate({ to: "/app/account/" } as any); + void navigate({ + to: "/app/account/", + search: { tab: "connections" }, + } as any); } }, [search.flow, navigate, queryClient]); diff --git a/apps/web/src/routes/yc/index.tsx b/apps/web/src/routes/yc/index.tsx index c17e1b4e8a..0b0b3b5aa4 100644 --- a/apps/web/src/routes/yc/index.tsx +++ b/apps/web/src/routes/yc/index.tsx @@ -1,13 +1,14 @@ import { ArrowUpRight, Check, CircleNotch } from "@phosphor-icons/react"; import { useForm } from "@tanstack/react-form"; import { useMutation } from "@tanstack/react-query"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { cn } from "@anlg/utils"; import { AnarlogLogo } from "@/components/anarlog-logo"; import { SiteFooter } from "@/components/site-footer"; -import { submitYcPerkRequest } from "@/functions/yc-perk"; +import { fetchUser } from "@/functions/auth"; +import { applyYcPerk, submitYcPerkRequest } from "@/functions/yc-perk"; import { getCanonicalUrl } from "@/lib/seo"; import { validateYcVerificationUrl, ycPerkRequestSchema } from "@/lib/yc-perk"; @@ -22,6 +23,7 @@ const invalidVerificationMessages = { export const Route = createFileRoute("/yc/")({ component: YcPerkPage, + loader: async () => ({ user: await fetchUser() }), head: () => ({ meta: [ { title }, @@ -38,11 +40,35 @@ export const Route = createFileRoute("/yc/")({ }); function YcPerkPage() { + const { user } = Route.useLoaderData(); + const navigate = useNavigate(); const requestMutation = useMutation({ - mutationFn: (data: { + mutationFn: async (data: { verificationUrl: string; additionalComments: string; - }) => submitYcPerkRequest({ data }), + }) => { + if (user) { + return applyYcPerk({ data: { value: data.verificationUrl } }); + } + return submitYcPerkRequest({ data }); + }, + onSuccess: (result) => { + if ( + result.status === "needs_checkout" && + "code" in result && + result.code + ) { + void navigate({ + to: "/app/checkout/", + search: { + period: "monthly", + trial: "false", + source: "yc_perk", + code: result.code, + }, + }); + } + }, }); const form = useForm({ defaultValues: { @@ -52,17 +78,24 @@ function YcPerkPage() { validators: { onSubmit: ycPerkRequestSchema }, onSubmit: ({ value }) => requestMutation.mutate(value), }); + const appliedToAccount = + requestMutation.data?.status === "applied" || + requestMutation.data?.status === "already_applied"; const requestSucceeded = requestMutation.data?.status === "verified" || - requestMutation.data?.status === "submitted"; + requestMutation.data?.status === "submitted" || + appliedToAccount; const invalidVerificationMessage = requestMutation.data?.status === "invalid" ? invalidVerificationMessages[requestMutation.data.reason] : undefined; const requestErrorMessage = - requestMutation.data?.status === "already_claimed" + requestMutation.data?.status === "already_claimed" || + requestMutation.data?.status === "claimed" ? "This perk has already been claimed." - : invalidVerificationMessage; + : requestMutation.data?.status === "invalid_code" + ? "This YC code is not valid." + : invalidVerificationMessage; return (
@@ -107,8 +140,18 @@ function YcPerkPage() { You’re verified.

- We sent your Pro code to your YC email. + {appliedToAccount + ? "Your YC year is on this account." + : "We sent your Pro code to your YC email."}

+ {appliedToAccount ? ( + + View account + + ) : null}
) : (
{ + let resp = proxy + .post( + "/auth.test", + Vec::new(), + "application/x-www-form-urlencoded", + ) + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + let auth: SlackAuthTest = resp.json().await.map_err(|e| e.to_string())?; + if auth.ok == Some(false) { + return Err(auth + .error + .unwrap_or_else(|| "slack auth.test failed".to_string())); + } + Ok((nonempty(auth.team), nonempty(auth.user))) + } + + "linear" => { + let body = serde_json::to_vec(&serde_json::json!({ + "query": "query OrganizationName { organization { name } }" + })) + .map_err(|e| e.to_string())?; + let resp = proxy + .post("/graphql", body, "application/json") + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + let payload: LinearOrganizationResponse = + resp.json().await.map_err(|e| e.to_string())?; + Ok(( + payload + .data + .and_then(|data| data.organization) + .and_then(|organization| nonempty(organization.name)), + None, + )) + } + + "github" => { + let resp = proxy + .get("/user") + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + let me: GithubUser = resp.json().await.map_err(|e| e.to_string())?; + Ok(( + nonempty(me.email).or_else(|| nonempty(me.login)), + nonempty(me.name), + )) + } + other => Err(format!("unsupported integration: {other}")), } } + +fn nonempty(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +pub(crate) fn account_identity_from_tags( + tags: Option<&std::collections::HashMap>, +) -> Option { + nonempty(tags.and_then(|tags| tags.get("account_identity").cloned())) +} + +pub(crate) async fn fetch_and_store_account_identity( + nango: &anlg_nango::NangoClient, + integration_id: &str, + connection_id: &str, +) -> Option { + let identity = match fetch_identity(nango, integration_id, connection_id).await { + Ok((identity, _display_name)) => identity?, + Err(e) => { + tracing::warn!( + anarlog.connection.id = %connection_id, + anarlog.integration.id = %integration_id, + error = %e, + "failed to fetch identity for account_identity tag" + ); + return None; + } + }; + + let mut tags = match nango.get_connection(connection_id, integration_id).await { + Ok(connection) => connection.tags.unwrap_or_default(), + Err(e) => { + tracing::warn!( + anarlog.connection.id = %connection_id, + anarlog.integration.id = %integration_id, + error = %e, + "failed to fetch connection before patching account_identity tag" + ); + return Some(identity); + } + }; + tags.insert("account_identity".to_string(), identity.clone()); + + let req = anlg_nango::PatchConnectionRequest { + end_user: None, + tags: Some(tags), + }; + + match nango + .patch_connection(connection_id, integration_id, req) + .await + { + Ok(()) => { + tracing::info!( + anarlog.connection.id = %connection_id, + anarlog.integration.id = %integration_id, + account_identity = %identity, + "account_identity tag set" + ); + } + Err(e) => { + tracing::warn!( + anarlog.connection.id = %connection_id, + anarlog.integration.id = %integration_id, + error = %e, + "failed to patch account_identity tag" + ); + } + } + + Some(identity) +} + +pub(crate) fn spawn_identity_task( + nango: anlg_nango::NangoClient, + integration_id: String, + connection_id: String, +) { + tokio::spawn(async move { + let _ = fetch_and_store_account_identity(&nango, &integration_id, &connection_id).await; + }); +} + +#[derive(serde::Deserialize)] +struct SlackAuthTest { + ok: Option, + team: Option, + user: Option, + error: Option, +} + +#[derive(serde::Deserialize)] +struct LinearOrganizationResponse { + data: Option, +} + +#[derive(serde::Deserialize)] +struct LinearOrganizationData { + organization: Option, +} + +#[derive(serde::Deserialize)] +struct LinearOrganization { + name: Option, +} + +#[derive(serde::Deserialize)] +struct GithubUser { + login: Option, + email: Option, + name: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::NangoConfig; + use crate::state::AppState; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + async fn nango_client(nango_mock: &MockServer) -> anlg_nango::NangoClient { + let supabase = MockServer::start().await; + AppState::new(NangoConfig::for_test(&nango_mock.uri(), &supabase.uri())).nango + } + + #[test] + fn account_identity_from_tags_trims_and_ignores_empty() { + let mut tags = std::collections::HashMap::new(); + tags.insert( + "account_identity".to_string(), + " john@fastrepl.com ".to_string(), + ); + assert_eq!( + account_identity_from_tags(Some(&tags)).as_deref(), + Some("john@fastrepl.com") + ); + + tags.insert("account_identity".to_string(), " ".to_string()); + assert_eq!(account_identity_from_tags(Some(&tags)), None); + assert_eq!(account_identity_from_tags(None), None); + } + + #[tokio::test] + async fn slack_identity_uses_workspace_name() { + let nango_mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/proxy/auth.test")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ok": true, + "team": "Fastrepl", + "user": "john" + }))) + .mount(&nango_mock) + .await; + + let nango = nango_client(&nango_mock).await; + let (identity, user) = fetch_identity(&nango, "slack", "conn-slack").await.unwrap(); + assert_eq!(identity.as_deref(), Some("Fastrepl")); + assert_eq!(user.as_deref(), Some("john")); + } + + #[tokio::test] + async fn linear_identity_uses_organization_name() { + let nango_mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/proxy/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { "organization": { "name": "Anarlog" } } + }))) + .mount(&nango_mock) + .await; + + let nango = nango_client(&nango_mock).await; + let (identity, display_name) = fetch_identity(&nango, "linear", "conn-linear") + .await + .unwrap(); + assert_eq!(identity.as_deref(), Some("Anarlog")); + assert_eq!(display_name, None); + } +} diff --git a/crates/api-nango/src/routes/status.rs b/crates/api-nango/src/routes/status.rs index 71f757e8a6..1ff77410e4 100644 --- a/crates/api-nango/src/routes/status.rs +++ b/crates/api-nango/src/routes/status.rs @@ -1,4 +1,5 @@ use anlg_api_auth::AuthContext; +use anlg_nango::ListConnectionsParams; use axum::{Extension, Json, extract::State}; use serde::Serialize; use utoipa::ToSchema; @@ -20,6 +21,8 @@ pub struct ConnectionItem { pub last_error_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub account_identity: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -46,18 +49,88 @@ pub async fn list_connections( .list_user_connections(&auth.token, &auth.claims.sub) .await?; - let connections = rows + let nango_connections = state + .nango + .list_connections(ListConnectionsParams { + end_user_id: Some(auth.claims.sub.clone()), + ..Default::default() + }) + .await + .unwrap_or_default(); + let nango_map: std::collections::HashMap<(&str, &str), _> = nango_connections + .iter() + .map(|connection| { + ( + ( + connection.provider_config_key.as_str(), + connection.connection_id.as_str(), + ), + connection, + ) + }) + .collect(); + + let mut connections: Vec = rows .into_iter() - .map(|row| ConnectionItem { - integration_id: row.integration_id, - connection_id: row.connection_id, - status: Some(row.status), - last_error_type: row.last_error_type, - last_error_description: row.last_error_description, - last_error_at: row.last_error_at, - updated_at: row.updated_at, + .map(|row| { + let nango = nango_map.get(&(row.integration_id.as_str(), row.connection_id.as_str())); + let account_identity = nango.and_then(|connection| { + super::identity::account_identity_from_tags(connection.tags.as_ref()) + }); + + ConnectionItem { + integration_id: row.integration_id, + connection_id: row.connection_id, + status: Some(row.status), + last_error_type: row.last_error_type, + last_error_description: row.last_error_description, + last_error_at: row.last_error_at, + updated_at: row.updated_at, + account_identity, + } }) .collect(); + let missing: Vec<(String, String)> = connections + .iter() + .filter(|item| { + item.account_identity.is_none() && item.status.as_deref() != Some("reconnect_required") + }) + .map(|item| (item.integration_id.clone(), item.connection_id.clone())) + .collect(); + + if !missing.is_empty() { + let resolved = futures_util::future::join_all(missing.into_iter().map( + |(integration_id, connection_id)| { + let nango = state.nango.clone(); + async move { + let identity = tokio::time::timeout( + std::time::Duration::from_secs(4), + super::identity::fetch_and_store_account_identity( + &nango, + &integration_id, + &connection_id, + ), + ) + .await + .ok() + .flatten(); + (connection_id, identity) + } + }, + )) + .await; + + for (connection_id, identity) in resolved { + if let Some(identity) = identity + && let Some(item) = connections + .iter_mut() + .find(|item| item.connection_id == connection_id) + { + item.account_identity = Some(identity); + } + } + } + Ok(Json(ListConnectionsResponse { connections })) } diff --git a/crates/api-nango/src/routes/webhook.rs b/crates/api-nango/src/routes/webhook.rs index eef2fa52aa..4fba0227e7 100644 --- a/crates/api-nango/src/routes/webhook.rs +++ b/crates/api-nango/src/routes/webhook.rs @@ -211,7 +211,7 @@ pub(crate) async fn handle_auth_webhook(state: &AppState, payload: NangoAuthWebh payload.operation, AuthOperation::Creation | AuthOperation::Override ) { - spawn_identity_task( + super::identity::spawn_identity_task( state.nango.clone(), payload.provider_config_key.clone(), payload.connection_id.clone(), @@ -222,71 +222,6 @@ pub(crate) async fn handle_auth_webhook(state: &AppState, payload: NangoAuthWebh Ok(()) } -fn spawn_identity_task( - nango: anlg_nango::NangoClient, - integration_id: String, - connection_id: String, -) { - tokio::spawn(async move { - match super::identity::fetch_identity(&nango, &integration_id, &connection_id).await { - Ok((email, _display_name)) => { - let Some(identity) = email else { - return; - }; - - let mut tags = match nango.get_connection(&connection_id, &integration_id).await { - Ok(connection) => connection.tags.unwrap_or_default(), - Err(e) => { - tracing::warn!( - anarlog.connection.id = %connection_id, - anarlog.integration.id = %integration_id, - error = %e, - "failed to fetch connection before patching account_identity tag" - ); - return; - } - }; - tags.insert("account_identity".to_string(), identity.clone()); - - let req = anlg_nango::PatchConnectionRequest { - end_user: None, - tags: Some(tags), - }; - - match nango - .patch_connection(&connection_id, &integration_id, req) - .await - { - Ok(()) => { - tracing::info!( - anarlog.connection.id = %connection_id, - anarlog.integration.id = %integration_id, - account_identity = %identity, - "account_identity tag set" - ); - } - Err(e) => { - tracing::warn!( - anarlog.connection.id = %connection_id, - anarlog.integration.id = %integration_id, - error = %e, - "failed to patch account_identity tag" - ); - } - } - } - Err(e) => { - tracing::warn!( - anarlog.connection.id = %connection_id, - anarlog.integration.id = %integration_id, - error = %e, - "failed to fetch identity for account_identity tag" - ); - } - } - }); -} - #[cfg(test)] mod tests { use wiremock::matchers::{body_json, method, path, path_regex, query_param}; diff --git a/crates/api-nango/src/routes/whoami.rs b/crates/api-nango/src/routes/whoami.rs index c369f7d4b1..a9774f7338 100644 --- a/crates/api-nango/src/routes/whoami.rs +++ b/crates/api-nango/src/routes/whoami.rs @@ -69,9 +69,9 @@ pub async fn whoami( let email = nango_map .get(&(row.integration_id.as_str(), row.connection_id.as_str())) - .and_then(|c| c.tags.as_ref()) - .and_then(|tags| tags.get("account_identity")) - .cloned(); + .and_then(|connection| { + super::identity::account_identity_from_tags(connection.tags.as_ref()) + }); WhoAmIItem { integration_id: row.integration_id, diff --git a/packages/api-client/src/generated/types.gen.ts b/packages/api-client/src/generated/types.gen.ts index b12d5a3922..3d794e67e0 100644 --- a/packages/api-client/src/generated/types.gen.ts +++ b/packages/api-client/src/generated/types.gen.ts @@ -310,6 +310,7 @@ export type Confidence = { }; export type ConnectionItem = { + account_identity?: string | null; connection_id: string; integration_id: string; last_error_at?: string | null; diff --git a/packages/supabase/src/billing.test.ts b/packages/supabase/src/billing.test.ts index ff7097632e..8b897a0c84 100644 --- a/packages/supabase/src/billing.test.ts +++ b/packages/supabase/src/billing.test.ts @@ -62,4 +62,22 @@ test("a bare active subscription does not invent an entitlement", () => { expect(billing.isPro).toBe(false); expect(billing.isPaid).toBe(false); expect(billing.plan).toBe("free"); + expect(billing.cancelAtPeriodEnd).toBe(false); + expect(billing.currentPeriodEnd).toBe(null); +}); + +test("a scheduled cancellation stays paid through the current period", () => { + const periodEnd = secondsFromNow(60 * 60 * 24 * 28); + const billing = deriveBillingInfo({ + entitlements: ["hyprnote_pro"], + subscription_status: "active", + cancel_at_period_end: true, + current_period_end: periodEnd, + }); + + expect(billing.isPaid).toBe(true); + expect(billing.isPro).toBe(true); + expect(billing.plan).toBe("pro"); + expect(billing.cancelAtPeriodEnd).toBe(true); + expect(billing.currentPeriodEnd?.getTime()).toBe(periodEnd * 1000); }); diff --git a/packages/supabase/src/billing.ts b/packages/supabase/src/billing.ts index 985643d02b..1a3a85101a 100644 --- a/packages/supabase/src/billing.ts +++ b/packages/supabase/src/billing.ts @@ -12,6 +12,8 @@ export type BillingInfo = { hasPaymentMethod: boolean; trialEnd: Date | null; trialDaysRemaining: number | null; + cancelAtPeriodEnd: boolean; + currentPeriodEnd: Date | null; plan: Plan; }; @@ -48,6 +50,9 @@ export function deriveBillingInfo( const isPaid = hasPaidEntitlement; const plan: Plan = isTrialing ? "trial" : hasPaidEntitlement ? "pro" : "free"; + const currentPeriodEnd = payload?.current_period_end + ? new Date(payload.current_period_end * 1000) + : null; return { entitlements, @@ -59,6 +64,8 @@ export function deriveBillingInfo( hasPaymentMethod: payload?.has_payment_method === true, trialEnd, trialDaysRemaining, + cancelAtPeriodEnd: payload?.cancel_at_period_end === true, + currentPeriodEnd, plan, }; } diff --git a/packages/supabase/src/jwt.ts b/packages/supabase/src/jwt.ts index 49a0e1ce56..2d640e1931 100644 --- a/packages/supabase/src/jwt.ts +++ b/packages/supabase/src/jwt.ts @@ -18,6 +18,8 @@ export type SupabaseJwtPayload = { subscription_status?: SubscriptionStatus | null; trial_end?: number | null; has_payment_method?: boolean | null; + cancel_at_period_end?: boolean | null; + current_period_end?: number | null; }; export type JwksVerifier = { diff --git a/supabase/migrations/20260820120000_auth_hook_subscription_cancel_claims.sql b/supabase/migrations/20260820120000_auth_hook_subscription_cancel_claims.sql new file mode 100644 index 0000000000..58cbc16a45 --- /dev/null +++ b/supabase/migrations/20260820120000_auth_hook_subscription_cancel_claims.sql @@ -0,0 +1,142 @@ +GRANT SELECT (subscription, current_period_end) +ON TABLE stripe.subscription_items +TO supabase_auth_admin; + +-- search_path is pinned here rather than left to the ALTER in +-- 20260714134923: CREATE OR REPLACE resets attributes it does not restate. +CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb) +RETURNS jsonb +LANGUAGE plpgsql +STABLE +SET search_path = '' +AS $$ +DECLARE + claims jsonb; + entitlements jsonb := '[]'::jsonb; + v_user_id uuid := (event->>'user_id')::uuid; + v_customer_id text; + v_subscription_status text; + v_trial_end bigint; + v_has_payment_method boolean; + v_cancel_at_period_end boolean; + v_current_period_end bigint; +BEGIN + SELECT p.stripe_customer_id INTO v_customer_id + FROM public.profiles p + WHERE p.id = v_user_id; + + SELECT + COALESCE( + jsonb_agg(DISTINCT granted.lookup_key ORDER BY granted.lookup_key) + FILTER (WHERE granted.lookup_key IS NOT NULL), + '[]'::jsonb + ) + INTO entitlements + FROM ( + SELECT ae.lookup_key + FROM public.profiles p + JOIN stripe.active_entitlements ae + ON ae.customer = p.stripe_customer_id + WHERE p.id = v_user_id + + UNION + + SELECT ae.lookup_key + FROM public.workspace_memberships m + JOIN public.workspaces w + ON w.id = m.workspace_id + JOIN stripe.active_entitlements ae + ON ae.customer = w.stripe_customer_id + WHERE m.user_id = v_user_id + AND m.deleted_at IS NULL + AND w.deleted_at IS NULL + AND w.stripe_customer_id IS NOT NULL + ) AS granted; + + IF v_customer_id IS NOT NULL THEN + SELECT + s.status::text, + (s.trial_end #>> '{}')::bigint, + s.default_payment_method IS NOT NULL + OR c.invoice_settings->>'default_payment_method' IS NOT NULL + OR c.default_source IS NOT NULL, + COALESCE(s.cancel_at_period_end, false), + -- cancel_at is the scheduled end; newer Stripe APIs keep the period on items. + COALESCE( + s.cancel_at, + s.current_period_end, + ( + SELECT MAX(si.current_period_end) + FROM stripe.subscription_items si + WHERE si.subscription = s.id + ) + ) + INTO + v_subscription_status, + v_trial_end, + v_has_payment_method, + v_cancel_at_period_end, + v_current_period_end + FROM stripe.subscriptions s + JOIN stripe.customers c ON c.id = s.customer + WHERE s.customer = v_customer_id + AND s.status IN ('trialing', 'active') + ORDER BY + CASE s.status WHEN 'active' THEN 1 WHEN 'trialing' THEN 2 END, + s.created DESC + LIMIT 1; + END IF; + + -- A member with no subscription of their own still reads as subscribed while + -- a workspace covers them; personal billing state wins when both exist. + IF v_subscription_status IS NULL THEN + SELECT s.status::text + INTO v_subscription_status + FROM public.workspace_memberships m + JOIN public.workspaces w + ON w.id = m.workspace_id + JOIN stripe.subscriptions s + ON s.customer = w.stripe_customer_id + WHERE m.user_id = v_user_id + AND m.deleted_at IS NULL + AND w.deleted_at IS NULL + AND w.stripe_customer_id IS NOT NULL + AND s.status IN ('trialing', 'active') + ORDER BY + CASE s.status WHEN 'active' THEN 1 WHEN 'trialing' THEN 2 END, + s.created DESC + LIMIT 1; + END IF; + + claims := event->'claims'; + claims := jsonb_set(claims, '{entitlements}', entitlements); + + IF v_subscription_status IS NOT NULL THEN + claims := jsonb_set(claims, '{subscription_status}', to_jsonb(v_subscription_status)); + END IF; + + IF v_trial_end IS NOT NULL THEN + claims := jsonb_set(claims, '{trial_end}', to_jsonb(v_trial_end)); + END IF; + + IF v_has_payment_method IS NOT NULL THEN + claims := jsonb_set(claims, '{has_payment_method}', to_jsonb(v_has_payment_method)); + END IF; + + IF v_cancel_at_period_end IS NOT NULL THEN + claims := jsonb_set( + claims, + '{cancel_at_period_end}', + to_jsonb(v_cancel_at_period_end) + ); + END IF; + + IF v_current_period_end IS NOT NULL THEN + claims := jsonb_set(claims, '{current_period_end}', to_jsonb(v_current_period_end)); + END IF; + + event := jsonb_set(event, '{claims}', claims); + + RETURN event; +END; +$$; diff --git a/supabase/tests/003-auth-custom-access-token-hook.sql b/supabase/tests/003-auth-custom-access-token-hook.sql index 66f1e430e6..716ba28c60 100644 --- a/supabase/tests/003-auth-custom-access-token-hook.sql +++ b/supabase/tests/003-auth-custom-access-token-hook.sql @@ -1,5 +1,5 @@ begin; -select plan(14); +select plan(19); select tests.create_supabase_user('pro', 'pro@example.com'); select tests.create_supabase_user('free', 'free@example.com'); @@ -222,5 +222,122 @@ select is( 'custom_access_token_hook reports a missing payment method' ); +select results_eq( + $$ + select bool_and(has_column_privilege('supabase_auth_admin', 'stripe.subscription_items', column_name, 'SELECT')) + from unnest(array['subscription', 'current_period_end']) as required_columns(column_name) + $$, + array[true], + 'supabase_auth_admin can read the subscription item columns used by the auth hook' +); + +select results_eq( + $$ + select ( + public.custom_access_token_hook( + jsonb_build_object( + 'user_id', tests.get_supabase_uid('active')::text, + 'claims', '{}'::jsonb + ) + ) -> 'claims' -> 'cancel_at_period_end' + )::text + $$, + array['false'], + 'custom_access_token_hook reports an active subscription that is not canceling' +); + +select tests.create_supabase_user('canceling', 'canceling@example.com'); + +update public.profiles +set stripe_customer_id = 'cus_canceling' +where id = tests.get_supabase_uid('canceling'); + +insert into stripe.customers (id) +values ('cus_canceling') +on conflict (id) do nothing; + +insert into stripe.subscriptions ( + id, + customer, + status, + cancel_at_period_end, + cancel_at, + current_period_end, + created +) +values ( + 'sub_canceling', + 'cus_canceling', + 'active', + true, + 1789603200, + 1789610000, + 3000 +) +on conflict (id) do nothing; + +select results_eq( + $$ + select ( + public.custom_access_token_hook( + jsonb_build_object( + 'user_id', tests.get_supabase_uid('canceling')::text, + 'claims', '{}'::jsonb + ) + ) -> 'claims' -> 'cancel_at_period_end' + )::text + $$, + array['true'], + 'custom_access_token_hook sets cancel_at_period_end for a scheduled cancellation' +); + +select results_eq( + $$ + select ( + public.custom_access_token_hook( + jsonb_build_object( + 'user_id', tests.get_supabase_uid('canceling')::text, + 'claims', '{}'::jsonb + ) + ) -> 'claims' -> 'current_period_end' + )::text + $$, + array['1789603200'], + 'custom_access_token_hook prefers cancel_at over subscription current_period_end' +); + +select tests.create_supabase_user('item_period', 'item-period@example.com'); + +update public.profiles +set stripe_customer_id = 'cus_item_period' +where id = tests.get_supabase_uid('item_period'); + +insert into stripe.customers (id) +values ('cus_item_period') +on conflict (id) do nothing; + +insert into stripe.subscriptions (id, customer, status, created) +values ('sub_item_period', 'cus_item_period', 'active', 4000) +on conflict (id) do nothing; + +insert into stripe.subscription_items (id, subscription, current_period_end) +values ('si_item_period', 'sub_item_period', 1789700000) +on conflict (id) do nothing; + +select results_eq( + $$ + select ( + public.custom_access_token_hook( + jsonb_build_object( + 'user_id', tests.get_supabase_uid('item_period')::text, + 'claims', '{}'::jsonb + ) + ) -> 'claims' -> 'current_period_end' + )::text + $$, + array['1789700000'], + 'custom_access_token_hook falls back to subscription item current_period_end' +); + select * from finish(); rollback;