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
6 changes: 6 additions & 0 deletions apps/api/openapi.gen.json
Original file line number Diff line number Diff line change
Expand Up @@ -6034,6 +6034,12 @@
"connection_id"
],
"properties": {
"account_identity": {
"type": [
"string",
"null"
]
},
"connection_id": {
"type": "string"
},
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/auth/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,6 +33,8 @@ export type BillingInfo = {
isTrialing: boolean;
trialEnd: Date | null;
trialDaysRemaining: number | null;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: Date | null;
plan: Plan;
};

Expand Down Expand Up @@ -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,
Expand All @@ -109,6 +116,8 @@ export function deriveBillingInfo(
isTrialing,
trialEnd,
trialDaysRemaining,
cancelAtPeriodEnd: payload?.cancel_at_period_end === true,
currentPeriodEnd,
plan,
};
}
34 changes: 34 additions & 0 deletions apps/web/src/functions/account-shares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
});
155 changes: 132 additions & 23 deletions apps/web/src/functions/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getSupabaseAdminClient,
getSupabaseServerClient,
} from "@/functions/supabase";
import { getSubscriptionAccessEnd } from "@/lib/account-plan";
import {
addInternalReturnPathSearch,
sanitizeInternalReturnPath,
Expand All @@ -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<typeof getSupabaseServerClient>;

Expand Down Expand Up @@ -71,7 +78,7 @@ class TrialCheckoutCreationError extends Error {
}
}

const getStripeCustomerIdForUser = async (
export const getStripeCustomerIdForUser = async (
supabase: SupabaseClient,
stripe: Stripe,
user: AuthUser,
Expand Down Expand Up @@ -161,11 +168,13 @@ const getProPriceId = (period: "monthly" | "yearly") => {
async function getCurrentSubscription(
stripe: Stripe,
stripeCustomerId: string,
options?: { expandDiscounts?: boolean },
): Promise<Stripe.Subscription | null> {
const subscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
status: "all",
limit: 10,
...(options?.expandDiscounts ? { expand: ["data.discounts"] } : {}),
});

return (
Expand Down Expand Up @@ -262,6 +271,19 @@ async function ensureStripeCustomerId(
return assignedCustomerId;
}

const ycPerkReturnSchema = z.enum(["applied", "claimed", "invalid"]);

function getAccountYcPerkUrl(
scheme: z.infer<typeof desktopSchemeSchema> | undefined,
perk: z.infer<typeof ycPerkReturnSchema>,
) {
if (scheme) {
return `${getBillingReturnUrl(scheme)}&perk=${perk}`;
}

return `${getRequestAppOrigin()}/app/account?perk=${perk}`;
Comment thread
cursor[bot] marked this conversation as resolved.
}

async function createCheckoutUrl({
supabase,
user,
Expand All @@ -272,6 +294,7 @@ async function createCheckoutUrl({
trialDays,
source = "unknown",
returnTo,
promotionCodeId,
}: {
supabase: SupabaseClient;
user: AuthUser & { email?: string | null };
Expand All @@ -282,6 +305,7 @@ async function createCheckoutUrl({
trialDays?: number;
source?: CheckoutSource;
returnTo?: string;
promotionCodeId?: string;
}) {
const stripe = getStripeClient();
const stripeCustomerId = await ensureStripeCustomerId(supabase, user);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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" })
Expand Down Expand Up @@ -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<typeof findYcPromotionCodeByCustomerCode>
> = 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,
Expand All @@ -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 };
}
}
}

Expand All @@ -476,6 +544,7 @@ export const createCheckoutSession = createServerFn({ method: "POST" })
trialDays,
source: data.source,
returnTo,
promotionCodeId: ycPromotion?.id,
Comment thread
cursor[bot] marked this conversation as resolved.
});
} catch (error) {
if (reservationId && !(error instanceof TrialCheckoutCreationError)) {
Expand Down Expand Up @@ -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" };
Expand All @@ -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),
};
},
);
Expand Down
Loading
Loading