Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Fixed promo codes greptile review comments
  • Loading branch information
AddarshKS committed Jun 27, 2026
commit cc7d87fac3f90793ae601c6935d05f834e1cafe2
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { purchaseUrlVerificationCodeHandler } from "@/app/api/latest/payments/purchases/verification-code-handler";
import { grantProductToCustomer, validatePurchaseSession } from "@/lib/payments";
import { markPromoCodeRedemptionApplied, reservePromoCodeRedemption } from "@/lib/payments/promo-codes";
import { markPromoCodeRedemptionApplied, reservePromoCodeRedemption, voidExpiredOrFailedPromoCodeRedemption } from "@/lib/payments/promo-codes";
import { upsertProductVersion } from "@/lib/product-versions";
import { getTenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared";
import { yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { HexclaveAssertionError, StatusError } from "@hexclave/shared/dist/utils/errors";
import { captureError, HexclaveAssertionError, StatusError } from "@hexclave/shared/dist/utils/errors";
import { Result } from "@hexclave/shared/dist/utils/results";

export const POST = createSmartRouteHandler({
metadata: {
Expand Down Expand Up @@ -73,25 +74,42 @@ export const POST = createSmartRouteHandler({
promoCode: promo_code,
}) : null;

const grantResult = await grantProductToCustomer({
prisma,
tenancy,
customerType: data.product.customerType,
customerId: data.customerId,
product: data.product,
productId: data.productId,
priceId: price_id,
quantity,
creationSource: "TEST_MODE",
});
if (promoRedemption) {
await markPromoCodeRedemptionApplied({
const grantResult = await Result.fromPromise((async () => {
Comment thread
AddarshKS marked this conversation as resolved.
Outdated
const granted = await grantProductToCustomer({
prisma,
tenancyId: tenancy.id,
redemptionId: promoRedemption.redemptionId,
subscriptionId: grantResult.type === "subscription" ? grantResult.subscriptionId : null,
oneTimePurchaseId: grantResult.type === "one_time" ? grantResult.purchaseId : null,
tenancy,
customerType: data.product.customerType,
customerId: data.customerId,
product: data.product,
productId: data.productId,
priceId: price_id,
quantity,
creationSource: "TEST_MODE",
});
if (promoRedemption) {
await markPromoCodeRedemptionApplied({
prisma,
tenancyId: tenancy.id,
redemptionId: promoRedemption.redemptionId,
subscriptionId: granted.type === "subscription" ? granted.subscriptionId : null,
oneTimePurchaseId: granted.type === "one_time" ? granted.purchaseId : null,
});
}
return granted;
})());
if (grantResult.status === "error") {
Comment thread
vercel[bot] marked this conversation as resolved.
if (promoRedemption) {
const voidResult = await Result.fromPromise(voidExpiredOrFailedPromoCodeRedemption({
prisma,
tenancyId: tenancy.id,
redemptionId: promoRedemption.redemptionId,
reason: "test_mode_grant_failed",
}));
if (voidResult.status === "error") {
captureError("test-mode-promo-redemption-void-failed", voidResult.error);
}
}
throw grantResult.error;
}
await purchaseUrlVerificationCodeHandler.revokeCode({
tenancy,
Expand Down

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion apps/backend/src/lib/payments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,17 @@ export function getClientSecretFromStripeSubscription(subscription: Stripe.Subsc
throwErr(500, "No client secret returned from Stripe for subscription");
}

export function getSetupIntentClientSecretFromStripeSubscription(subscription: Stripe.Subscription): string {
const pendingSetupIntent = Reflect.get(subscription, "pending_setup_intent");
if (pendingSetupIntent && typeof pendingSetupIntent === "object") {
const clientSecret = Reflect.get(pendingSetupIntent, "client_secret");
if (typeof clientSecret === "string") {
return clientSecret;
}
}
throwErr(500, "No setup intent client secret returned from Stripe for subscription");
}

type GrantProductResult =
| {
type: "one_time",
Expand Down Expand Up @@ -617,4 +628,3 @@ export async function grantProductToCustomer(options: {

return { type: "subscription", subscriptionId: subscription.id };
}

27 changes: 14 additions & 13 deletions apps/backend/src/lib/payments/promo-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ type PromoCodePurchaseContext = {
};

const RESERVATION_TTL_MS = 15 * 60 * 1000;
export const PROMO_CODE_NOT_AVAILABLE_MESSAGE = "Promo code is invalid or not available for this purchase.";

function enumCustomerType(customerType: CustomerType): "USER" | "TEAM" | "CUSTOM" {
switch (customerType) {
Expand Down Expand Up @@ -218,31 +219,31 @@ function assertPromoCodeApplies(options: {
}) {
const promoCode = options.promoCode;
if (promoCode.deletedAt != null) {
throw new StatusError(400, "Promo code does not exist.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
if (promoCode.disabledAt != null) {
throw new StatusError(400, "Promo code is disabled.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
if (promoCode.startsAt != null && promoCode.startsAt > options.now) {
throw new StatusError(400, "Promo code is not active yet.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
if (promoCode.expiresAt != null && promoCode.expiresAt <= options.now) {
throw new StatusError(400, "Promo code has expired.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
if (promoCode.customerType != null && promoCode.customerType !== enumCustomerType(options.customerType)) {
throw new StatusError(400, "Promo code is not available for this customer.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
if (promoCode.customerId != null && promoCode.customerId !== options.customerId) {
throw new StatusError(400, "Promo code is not available for this customer.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
if (promoCode.productLineId != null && promoCode.productLineId !== (options.product.productLineId ?? null)) {
throw new StatusError(400, "Promo code is not available for this product line.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
if (promoCode.productId != null && promoCode.productId !== (options.productId ?? null)) {
throw new StatusError(400, "Promo code is not available for this product.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
if (promoCode.priceId != null && promoCode.priceId !== (options.priceId ?? null)) {
throw new StatusError(400, "Promo code is not available for this price.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
}
Comment thread
AddarshKS marked this conversation as resolved.

Expand Down Expand Up @@ -309,10 +310,10 @@ async function assertRedemptionLimits(options: {
`;
const counts = rows[0] ?? { totalCount: 0n, customerCount: 0n };
if (options.promoCode.maxRedemptions != null && counts.totalCount >= BigInt(options.promoCode.maxRedemptions)) {
throw new StatusError(400, "Promo code has reached its redemption limit.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
if (options.promoCode.maxRedemptionsPerCustomer != null && counts.customerCount >= BigInt(options.promoCode.maxRedemptionsPerCustomer)) {
throw new StatusError(400, "Promo code has already been used by this customer.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
}

Expand All @@ -327,7 +328,7 @@ export async function quotePromoCodeForPurchase(options: PromoCodePurchaseContex
codeHash: hashPromoCode(normalized),
});
if (!promoCode) {
throw new StatusError(400, "Promo code does not exist.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
const now = new Date();
assertPromoCodeApplies({
Expand Down Expand Up @@ -420,7 +421,7 @@ export async function reservePromoCodeRedemption(options: PromoCodePurchaseConte
`;
const redemptionId = rows[0]?.id;
if (!redemptionId) {
throw new StatusError(400, "Promo code has reached its redemption limit.");
throw new StatusError(400, PROMO_CODE_NOT_AVAILABLE_MESSAGE);
}
return {
...quote,
Expand Down
40 changes: 34 additions & 6 deletions apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ type InvalidPromoQuote = {
error?: string,
};

type PurchaseSessionResult = {
client_secret?: string,
client_secret_type?: "payment" | "setup",
};

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
Expand Down Expand Up @@ -84,6 +89,16 @@ function parsePromoQuote(value: unknown): PromoQuote | InvalidPromoQuote {
};
}

function parsePurchaseSessionResult(value: unknown): PurchaseSessionResult {
if (!isObject(value)) {
return {};
}
return {
client_secret: typeof value.client_secret === "string" ? value.client_secret : undefined,
client_secret_type: value.client_secret_type === "payment" || value.client_secret_type === "setup" ? value.client_secret_type : undefined,
};
}

function formatCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}
Expand Down Expand Up @@ -127,6 +142,14 @@ export default function PageClient({ code }: { code: string }) {

const isTooLarge = rawAmountCents > MAX_STRIPE_AMOUNT_CENTS;

const isSetupAfterFirstInvoicePromo = useMemo(() => {
if (!selectedPriceId || !data?.product?.prices || !promoQuote) {
return false;
}
const price = data.product.prices[selectedPriceId];
return Boolean(price.interval) && promoQuote.final_amount_usd_cents === 0 && promoQuote.subscription_duration === "first_invoice";
}, [data, selectedPriceId, promoQuote]);

const elementsAmountCents = useMemo(() => {
if (promoQuote && promoQuote.final_amount_usd_cents > 0) {
return promoQuote.final_amount_usd_cents;
Expand All @@ -137,11 +160,12 @@ export default function PageClient({ code }: { code: string }) {
return rawAmountCents;
}, [promoQuote, unitCents, rawAmountCents, isTooLarge]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const elementsMode = useMemo<"subscription" | "payment">(() => {
const elementsMode = useMemo<"subscription" | "payment" | "setup">(() => {
if (isSetupAfterFirstInvoicePromo) return "setup";
if (!selectedPriceId || !data?.product?.prices) return "subscription";
const price = data.product.prices[selectedPriceId];
return price.interval ? "subscription" : "payment";
}, [data, selectedPriceId]);
}, [data, selectedPriceId, isSetupAfterFirstInvoicePromo]);

const validateCode = useCallback(async () => {
const response = await fetch(`${baseUrl}/payments/purchases/validate-code`, {
Expand Down Expand Up @@ -195,8 +219,8 @@ export default function PageClient({ code }: { code: string }) {
}, [data, selectedPriceId]);

const isFreeForCheckout = useMemo<boolean>(() => {
return isFreeSelected || (elementsMode === "subscription" && promoQuote?.final_amount_usd_cents === 0);
}, [elementsMode, isFreeSelected, promoQuote]);
return isFreeSelected || (promoQuote?.final_amount_usd_cents === 0 && promoQuote.subscription_duration === "forever");
}, [isFreeSelected, promoQuote]);

const applyPromoCode = async () => {
if (!selectedPriceId || quantityNumber < 1 || !promoCodeInput.trim()) {
Expand Down Expand Up @@ -256,10 +280,14 @@ export default function PageClient({ code }: { code: string }) {
throw new Error(result?.error?.message ?? "Failed to setup subscription");
}

if (!result.client_secret && !isFreeForCheckout) {
const parsedResult = parsePurchaseSessionResult(result);
if (!parsedResult.client_secret && !isFreeForCheckout) {
throw new Error("Failed to setup subscription");
}
return result.client_secret;
return {
clientSecret: parsedResult.client_secret,
clientSecretType: parsedResult.client_secret_type ?? "payment",
};
};

const handleBypass = useCallback(async () => {
Expand Down
35 changes: 33 additions & 2 deletions apps/dashboard/src/app/(main)/purchase/return/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Props = {
redirectStatus?: string,
paymentIntentId?: string,
clientSecret?: string,
setupIntentClientSecret?: string,
stripeAccountId?: string,
purchaseFullCode?: string,
bypass?: string,
Expand All @@ -30,7 +31,7 @@ const stripePublicKey = getPublicEnvVar("NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KE
const apiUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set");
const baseUrl = new URL("/api/v1", apiUrl).toString();

export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFullCode, bypass, free }: Props) {
export default function ReturnClient({ clientSecret, setupIntentClientSecret, stripeAccountId, purchaseFullCode, bypass, free }: Props) {
const [state, setState] = useState<ViewState>({ kind: "loading" });
const searchParams = useSearchParams();
const returnUrl = searchParams.get("return_url");
Expand Down Expand Up @@ -67,6 +68,36 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu
}
const stripe = await loadStripe(stripePublicKey, { stripeAccount: stripeAccountId });
if (!stripe) throw new Error("Stripe failed to initialize");
if (setupIntentClientSecret) {
const result = await stripe.retrieveSetupIntent(setupIntentClientSecret);
const status = result.setupIntent?.status;
const lastErrorMessage = result.setupIntent?.last_setup_error?.message;

if (status === "succeeded") {
runAsynchronously(checkAndReturnUser());
const message = `Payment method saved.${returnUrl ? " You will be redirected shortly." : " You can safely close this page."}`;
setState({ kind: "success", message });
return;
}
if (status === "processing") {
setState({ kind: "success", message: "Payment method setup is processing. You'll receive an update shortly." });
return;
}
if (status === "requires_payment_method") {
setState({ kind: "error", message: lastErrorMessage ?? "Payment method setup failed. Please try another payment method." });
return;
}
if (status === "requires_action") {
setState({ kind: "error", message: "Additional authentication required. Please try again." });
return;
}
if (status === "canceled") {
setState({ kind: "error", message: "Payment method setup was canceled." });
return;
}
setState({ kind: "error", message: "Unexpected setup state." });
return;
}
if (!clientSecret) return;
const result = await stripe.retrievePaymentIntent(clientSecret);
const status = result.paymentIntent?.status;
Expand Down Expand Up @@ -99,7 +130,7 @@ export default function ReturnClient({ clientSecret, stripeAccountId, purchaseFu
const message = e instanceof Error ? e.message : "Unexpected error retrieving payment.";
setState({ kind: "error", message });
}
}, [clientSecret, stripeAccountId, bypass, free, returnUrl, checkAndReturnUser]);
}, [clientSecret, setupIntentClientSecret, stripeAccountId, bypass, free, returnUrl, checkAndReturnUser]);

useEffect(() => {
runAsynchronously(updateViewState());
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/app/(main)/purchase/return/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type Props = {
redirect_status?: string,
payment_intent?: string,
payment_intent_client_secret?: string,
setup_intent_client_secret?: string,
stripe_account_id?: string,
purchase_full_code?: string,
bypass?: string,
Expand All @@ -20,6 +21,7 @@ export default async function Page({ searchParams }: Props) {
redirectStatus={params.redirect_status}
paymentIntentId={params.payment_intent}
clientSecret={params.payment_intent_client_secret}
setupIntentClientSecret={params.setup_intent_client_secret}
stripeAccountId={params.stripe_account_id}
purchaseFullCode={params.purchase_full_code}
bypass={params.bypass}
Expand Down
Loading