diff --git a/apps/web/app/(ee)/api/cron/discount-codes/create/queue-batches/route.ts b/apps/web/app/(ee)/api/cron/discount-codes/create/queue-batches/route.ts index cca8e0d2bab..cfb9461ed88 100644 --- a/apps/web/app/(ee)/api/cron/discount-codes/create/queue-batches/route.ts +++ b/apps/web/app/(ee)/api/cron/discount-codes/create/queue-batches/route.ts @@ -1,7 +1,7 @@ import { CRON_BATCH_SIZE, qstash } from "@/lib/cron"; import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; import { withCron } from "@/lib/cron/with-cron"; -import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error"; +import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error"; import { getDiscountProvider } from "@/lib/discounts/discount-provider"; import { prisma } from "@/lib/prisma"; import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; @@ -55,14 +55,12 @@ export const POST = withCron(async ({ rawBody }) => { const discountProvider = getDiscountProvider(discount.provider); try { - await discountProvider.assertDiscountIntegrationAvailable({ + await discountProvider.assertDiscountIntegration({ workspace: program.workspace, }); } catch (error) { - if (isDiscountIntegrationNotAvailableError(error)) { - return logAndRespond( - `Workspace has not installed the ${discount.provider} integration. Skipping...`, - ); + if (isNonRecoverableDiscountError(error)) { + return logAndRespond(error.message, { logLevel: "warn" }); } throw error; diff --git a/apps/web/app/(ee)/api/cron/discount-codes/create/route.ts b/apps/web/app/(ee)/api/cron/discount-codes/create/route.ts index 0f231bfc00d..4888d4eafe4 100644 --- a/apps/web/app/(ee)/api/cron/discount-codes/create/route.ts +++ b/apps/web/app/(ee)/api/cron/discount-codes/create/route.ts @@ -1,6 +1,6 @@ import { withCron } from "@/lib/cron/with-cron"; import { createDiscountCode } from "@/lib/discounts/create-discount-code"; -import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error"; +import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error"; import { prisma } from "@/lib/prisma"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; @@ -90,20 +90,7 @@ export const POST = withCron(async ({ rawBody }) => { discount, }); } catch (error) { - if (isDiscountIntegrationNotAvailableError(error)) { - return logAndRespond( - `Workspace has not installed the ${discount.provider} integration. Skipping...`, - ); - } - - // Eg: This application does not have the required permissions for this endpoint on account 'acct_xxx'. - // Having the 'read_write' scope would allow this request to continue. - if ( - error instanceof Error && - error.message.includes( - "This application does not have the required permissions", - ) - ) { + if (isNonRecoverableDiscountError(error)) { return logAndRespond(error.message, { logLevel: "warn" }); } diff --git a/apps/web/app/(ee)/api/cron/discount-codes/disable/route.ts b/apps/web/app/(ee)/api/cron/discount-codes/disable/route.ts index 7b4792d8c51..75ec424f80d 100644 --- a/apps/web/app/(ee)/api/cron/discount-codes/disable/route.ts +++ b/apps/web/app/(ee)/api/cron/discount-codes/disable/route.ts @@ -1,5 +1,5 @@ import { withCron } from "@/lib/cron/with-cron"; -import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error"; +import { isNonRecoverableDiscountError } from "@/lib/discounts/discount-error"; import { getDiscountProvider } from "@/lib/discounts/discount-provider"; import { prisma } from "@/lib/prisma"; import { DiscountProvider } from "@prisma/client"; @@ -37,8 +37,10 @@ export const POST = withCron(async ({ rawBody }) => { code, }); } catch (error) { - if (isDiscountIntegrationNotAvailableError(error)) { - return logAndRespond(`Skipping ${code}: ${error.message}`); + if (isNonRecoverableDiscountError(error)) { + return logAndRespond(`Skipping ${code}: ${error.message}`, { + logLevel: "warn", + }); } throw error; diff --git a/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts b/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts index e0a79faa107..a49f8dd9f68 100644 --- a/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts +++ b/apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts @@ -1,7 +1,7 @@ import { withCron } from "@/lib/cron/with-cron"; import { createDiscountCode } from "@/lib/discounts/create-discount-code"; import { deleteDiscountCodes } from "@/lib/discounts/delete-discount-code"; -import { isDiscountIntegrationNotAvailableError } from "@/lib/discounts/discount-error"; +import { isDiscountProviderError } from "@/lib/discounts/discount-error"; import { isDiscountEquivalent } from "@/lib/discounts/is-discount-equivalent"; import { prisma } from "@/lib/prisma"; import { Discount, DiscountCode } from "@prisma/client"; @@ -164,13 +164,19 @@ export const POST = withCron(async ({ rawBody }) => { discount: group.discount, }); } catch (error) { - if (isDiscountIntegrationNotAvailableError(error)) { - console.warn( - `Workspace has not installed the ${group.discount.provider} integration. Skipping remaining discount code creation for remap.`, - ); - break; + if (isDiscountProviderError(error)) { + if ( + error.providerCode === "INTEGRATION_NOT_AVAILABLE" || + error.providerCode === "AUTH_EXPIRED" || + error.providerCode === "PERMISSIONS_REQUIRED" || + error.providerCode === "COUPON_NOT_FOUND" + ) { + console.warn( + `${error.message} Skipping remaining discount code creation for remap.`, + ); + break; + } } - throw error; } } diff --git a/apps/web/lib/actions/partners/create-discount.ts b/apps/web/lib/actions/partners/create-discount.ts index 81ed466f452..2dbcc188d62 100644 --- a/apps/web/lib/actions/partners/create-discount.ts +++ b/apps/web/lib/actions/partners/create-discount.ts @@ -67,7 +67,7 @@ export const createDiscountAction = authActionClient }); } } else if (provider === DiscountProvider.shopify) { - await discountProvider.assertDiscountIntegrationAvailable({ + await discountProvider.assertDiscountIntegration({ workspace, }); } diff --git a/apps/web/lib/discounts/create-discount-code.ts b/apps/web/lib/discounts/create-discount-code.ts index fd8e9153f34..d6d5b02c014 100644 --- a/apps/web/lib/discounts/create-discount-code.ts +++ b/apps/web/lib/discounts/create-discount-code.ts @@ -41,33 +41,12 @@ export async function createDiscountCode({ const discountProvider = getDiscountProvider(discount.provider); - let externalDiscountCode: Awaited< - ReturnType - >; - - try { - externalDiscountCode = await discountProvider.createDiscountCode({ - workspace, - discount, - code: finalCode, - shouldRetry: code ? false : true, - }); - } catch (error) { - const message = error?.raw?.message || error?.message || ""; - const isDuplicateCode = - message.includes("already exists") || - error?.code === "TAKEN" || - error?.code === "DUPLICATE"; - - if (isDuplicateCode) { - throw new DubApiError({ - code: "conflict", - message: `The discount code ${finalCode} is already in use. Please choose a different code.`, - }); - } - - throw error; - } + const externalDiscountCode = await discountProvider.createDiscountCode({ + workspace, + discount, + code: finalCode, + shouldRetry: code ? false : true, + }); try { return await prisma.discountCode.create({ diff --git a/apps/web/lib/discounts/discount-error.ts b/apps/web/lib/discounts/discount-error.ts index 4b0a6b2ded0..5776a09ba10 100644 --- a/apps/web/lib/discounts/discount-error.ts +++ b/apps/web/lib/discounts/discount-error.ts @@ -1,15 +1,78 @@ import { DubApiError } from "../api/errors"; -export class DiscountIntegrationNotAvailableError extends DubApiError { - constructor({ message }: { message: string }) { - super({ code: "bad_request", message }); - this.name = "DiscountIntegrationNotAvailableError"; +export type DiscountProviderErrorCode = + | "INTEGRATION_NOT_AVAILABLE" + | "AUTH_EXPIRED" + | "DISCOUNT_ALREADY_EXISTS" + | "COUPON_NOT_FOUND" + | "PERMISSIONS_REQUIRED" + | "CREATE_FAILED"; + +const API_CODE_BY_PROVIDER_CODE: Record< + DiscountProviderErrorCode, + "bad_request" | "conflict" | "internal_server_error" +> = { + INTEGRATION_NOT_AVAILABLE: "bad_request", + AUTH_EXPIRED: "bad_request", + DISCOUNT_ALREADY_EXISTS: "conflict", + COUPON_NOT_FOUND: "bad_request", + PERMISSIONS_REQUIRED: "bad_request", + CREATE_FAILED: "internal_server_error", +}; + +function resolveDiscountProviderMessage( + provider: "stripe" | "shopify", + providerCode: DiscountProviderErrorCode, + message: string, +): string { + if (providerCode === "INTEGRATION_NOT_AVAILABLE") { + return provider === "stripe" + ? "STRIPE_CONNECTION_REQUIRED: Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create a discount." + : "SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount."; + } + + if (providerCode === "AUTH_EXPIRED") { + return provider === "stripe" + ? "STRIPE_RECONNECT_REQUIRED: Your Stripe connection has expired or been revoked. Please reconnect the Dub Stripe app in settings." + : "SHOPIFY_RECONNECT_REQUIRED: Your Shopify connection has expired or been revoked. Please reconnect the Dub Shopify app in settings."; + } + + if (providerCode === "PERMISSIONS_REQUIRED") { + return provider === "stripe" + ? "STRIPE_APP_UPGRADE_REQUIRED: Your connected Stripe account doesn't have the permissions needed to create discount codes. Please upgrade your Stripe integration in settings or reach out to our support team for help." + : "SHOPIFY_APP_UPGRADE_REQUIRED: Your connected Shopify store doesn't have permission to create discount codes. Please reinstall or upgrade the Dub Shopify app."; + } + + return message; +} + +export class DiscountProviderError extends DubApiError { + constructor( + public readonly provider: "stripe" | "shopify", + public readonly providerCode: DiscountProviderErrorCode, + message: string, + ) { + super({ + code: API_CODE_BY_PROVIDER_CODE[providerCode], + message: resolveDiscountProviderMessage(provider, providerCode, message), + }); + this.name = "DiscountProviderError"; Object.setPrototypeOf(this, new.target.prototype); } + + get isRecoverable() { + return this.providerCode === "CREATE_FAILED"; + } +} + +export function isDiscountProviderError( + error: unknown, +): error is DiscountProviderError { + return error instanceof DiscountProviderError; } -export function isDiscountIntegrationNotAvailableError( +export function isNonRecoverableDiscountError( error: unknown, -): error is DiscountIntegrationNotAvailableError { - return error instanceof DiscountIntegrationNotAvailableError; +): error is DiscountProviderError { + return isDiscountProviderError(error) && !error.isRecoverable; } diff --git a/apps/web/lib/discounts/discount-provider-shopify.ts b/apps/web/lib/discounts/discount-provider-shopify.ts index 373a17998e4..4f86f5be437 100644 --- a/apps/web/lib/discounts/discount-provider-shopify.ts +++ b/apps/web/lib/discounts/discount-provider-shopify.ts @@ -7,7 +7,7 @@ import { shopifyAdminGraphql, } from "../integrations/shopify/admin-graphql"; import { integrationCredentialsSchema } from "../integrations/shopify/schema"; -import { DiscountIntegrationNotAvailableError } from "./discount-error"; +import { DiscountProviderError } from "./discount-error"; interface ShopifyDiscountCodeBasicCreate { codeDiscountNode: { @@ -38,10 +38,11 @@ async function requireInstalledIntegration( workspace: Pick, ) { if (!workspace.shopifyStoreId) { - throw new DiscountIntegrationNotAvailableError({ - message: - "SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.", - }); + throw new DiscountProviderError( + "shopify", + "INTEGRATION_NOT_AVAILABLE", + "SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.", + ); } const installation = await prisma.installedIntegration.findFirst({ @@ -52,10 +53,11 @@ async function requireInstalledIntegration( }); if (!installation) { - throw new DiscountIntegrationNotAvailableError({ - message: - "SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.", - }); + throw new DiscountProviderError( + "shopify", + "INTEGRATION_NOT_AVAILABLE", + "SHOPIFY_CONNECTION_REQUIRED: Your workspace isn't connected to Shopify yet. Please install the Dub Shopify app in settings to create a discount.", + ); } let credentials = integrationCredentialsSchema.parse( @@ -63,10 +65,11 @@ async function requireInstalledIntegration( ); if (!credentials?.scope?.includes("write_discounts")) { - throw new DiscountIntegrationNotAvailableError({ - message: - "SHOPIFY_APP_UPGRADE_REQUIRED: Your connected Shopify store doesn't have permission to create discount codes. Please reinstall or upgrade the Dub Shopify app.", - }); + throw new DiscountProviderError( + "shopify", + "PERMISSIONS_REQUIRED", + "SHOPIFY_APP_UPGRADE_REQUIRED: Your connected Shopify store doesn't have permission to create discount codes. Please reinstall or upgrade the Dub Shopify app.", + ); } return { @@ -80,6 +83,15 @@ async function requireInstalledIntegration( }; } +function throwIfShopifyUnauthorized(error: unknown): void { + if ( + error instanceof ShopifyAdminGraphqlError && + error.code === "unauthorized" + ) { + throw new DiscountProviderError("shopify", "AUTH_EXPIRED", error.message); + } +} + function createShopifyDiscountProvider() { const getCoupon = async () => { throw new Error("Shopify does not this method."); @@ -187,7 +199,7 @@ function createShopifyDiscountProvider() { if (!codeDiscountNode) { throw new ShopifyAdminGraphqlError( "no_node_returned", - "Shopify did not return a discount code node.", + "Shopify did not return a discount code. Please try again.", ); } @@ -199,27 +211,64 @@ function createShopifyDiscountProvider() { error instanceof ShopifyAdminGraphqlError && (error.code === "TAKEN" || error.code === "DUPLICATE"); - if (!isDuplicate || !shouldRetry) { - throw error; - } + if (isDuplicate) { + if (!shouldRetry) { + throw new DiscountProviderError( + "shopify", + "DISCOUNT_ALREADY_EXISTS", + `The discount code ${currentCode} is already in use. Please choose a different code.`, + ); + } - attempt++; + attempt++; - if (attempt >= MAX_ATTEMPTS) { - throw error; + if (attempt >= MAX_ATTEMPTS) { + throw new DiscountProviderError( + "shopify", + "CREATE_FAILED", + `Failed to create a unique discount code after ${MAX_ATTEMPTS} attempts. Please try again.`, + ); + } + + const newCode = `${currentCode}${nanoid(2)}`; + + console.warn( + `Discount code "${currentCode}" already exists in Shopify. Retrying with "${newCode}" (attempt ${attempt}/${MAX_ATTEMPTS}).`, + ); + + currentCode = newCode; + continue; } - const newCode = `${currentCode}${nanoid(2)}`; + if (error instanceof ShopifyAdminGraphqlError) { + throwIfShopifyUnauthorized(error); + + throw new DiscountProviderError( + "shopify", + "CREATE_FAILED", + error.code === "no_node_returned" + ? "Shopify did not return a discount code. Please try again." + : error.code === "http_error" || error.code === "graphql_error" + ? `Unable to create the discount code in Shopify. ${error.message}` + : error.message, + ); + } - console.warn( - `Discount code "${currentCode}" already exists in Shopify. Retrying with "${newCode}" (attempt ${attempt}/${MAX_ATTEMPTS}).`, + throw new DiscountProviderError( + "shopify", + "CREATE_FAILED", + error instanceof Error + ? error.message + : "Failed to create Shopify discount code.", ); - - currentCode = newCode; } } - throw new Error("Failed to create Shopify discount code."); + throw new DiscountProviderError( + "shopify", + "CREATE_FAILED", + "Failed to create Shopify discount code.", + ); }; const disableDiscountCode = async ({ @@ -231,68 +280,76 @@ function createShopifyDiscountProvider() { }) => { const { credentials } = await requireInstalledIntegration(workspace); - const lookup = await shopifyAdminGraphql<{ - codeDiscountNodeByCode: { id: string } | null; - }>({ - shopifyStoreId: workspace.shopifyStoreId!, - accessToken: credentials.accessToken!, - query: /* GraphQL */ ` - query CodeDiscountNodeByCode($code: String!) { - codeDiscountNodeByCode(code: $code) { - id + try { + const lookup = await shopifyAdminGraphql<{ + codeDiscountNodeByCode: { id: string } | null; + }>({ + shopifyStoreId: workspace.shopifyStoreId!, + accessToken: credentials.accessToken!, + query: /* GraphQL */ ` + query CodeDiscountNodeByCode($code: String!) { + codeDiscountNodeByCode(code: $code) { + id + } } - } - `, - variables: { code }, - }); + `, + variables: { code }, + }); - const id = lookup.codeDiscountNodeByCode?.id; + const id = lookup.codeDiscountNodeByCode?.id; - if (!id) { - console.error( - `Shopify discount code ${code} not found (shopifyStoreId=${workspace.shopifyStoreId!}).`, - ); - return; - } + if (!id) { + console.error( + `Shopify discount code ${code} not found (shopifyStoreId=${workspace.shopifyStoreId!}).`, + ); + return; + } - const data = await shopifyAdminGraphql<{ - discountCodeDelete: ShopifyDiscountCodeDelete; - }>({ - shopifyStoreId: workspace.shopifyStoreId!, - accessToken: credentials.accessToken!, - query: /* GraphQL */ ` - mutation DiscountCodeDelete($id: ID!) { - discountCodeDelete(id: $id) { - deletedCodeDiscountId - userErrors { - field - message - code + const data = await shopifyAdminGraphql<{ + discountCodeDelete: ShopifyDiscountCodeDelete; + }>({ + shopifyStoreId: workspace.shopifyStoreId!, + accessToken: credentials.accessToken!, + query: /* GraphQL */ ` + mutation DiscountCodeDelete($id: ID!) { + discountCodeDelete(id: $id) { + deletedCodeDiscountId + userErrors { + field + message + code + } } } - } - `, - variables: { id }, - }); + `, + variables: { id }, + }); - const { userErrors } = data.discountCodeDelete; + const { userErrors } = data.discountCodeDelete; - if (userErrors.length > 0) { - throw new ShopifyAdminGraphqlError( - userErrors[0].code, - userErrors[0].message, - userErrors, - ); - } + if (userErrors.length > 0) { + throw new ShopifyAdminGraphqlError( + userErrors[0].code, + userErrors[0].message, + userErrors, + ); + } - console.info( - `Deleted Shopify discount code ${code} (id=${id}, shopifyStoreId=${workspace.shopifyStoreId}).`, - ); + console.info( + `Deleted Shopify discount code ${code} (id=${id}, shopifyStoreId=${workspace.shopifyStoreId}).`, + ); - return { id, code }; + return { + id, + code, + }; + } catch (error) { + throwIfShopifyUnauthorized(error); + throw error; + } }; - const assertDiscountIntegrationAvailable = async ({ + const assertDiscountIntegration = async ({ workspace, }: { workspace: Pick; @@ -305,7 +362,7 @@ function createShopifyDiscountProvider() { createCoupon, createDiscountCode, disableDiscountCode, - assertDiscountIntegrationAvailable, + assertDiscountIntegration, }; } diff --git a/apps/web/lib/discounts/discount-provider-stripe.ts b/apps/web/lib/discounts/discount-provider-stripe.ts index fe4022563eb..7910499fa36 100644 --- a/apps/web/lib/discounts/discount-provider-stripe.ts +++ b/apps/web/lib/discounts/discount-provider-stripe.ts @@ -12,7 +12,7 @@ import { } from "../stripe/coupon-discount-converter"; import { DiscountProps } from "../types"; import { createDiscountSchema } from "../zod/schemas/discount"; -import { DiscountIntegrationNotAvailableError } from "./discount-error"; +import { DiscountProviderError } from "./discount-error"; const MAX_ATTEMPTS = 3; @@ -20,10 +20,11 @@ async function requireInstalledIntegration( workspace: Pick, ) { if (!workspace.stripeConnectId) { - throw new DiscountIntegrationNotAvailableError({ - message: - "STRIPE_CONNECTION_REQUIRED: Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create a discount.", - }); + throw new DiscountProviderError( + "stripe", + "INTEGRATION_NOT_AVAILABLE", + "STRIPE_CONNECTION_REQUIRED: Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create a discount.", + ); } const installation = await prisma.installedIntegration.findFirst({ @@ -34,10 +35,11 @@ async function requireInstalledIntegration( }); if (!installation) { - throw new DiscountIntegrationNotAvailableError({ - message: - "STRIPE_CONNECTION_REQUIRED: Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create a discount.", - }); + throw new DiscountProviderError( + "stripe", + "INTEGRATION_NOT_AVAILABLE", + "STRIPE_CONNECTION_REQUIRED: Your workspace isn't connected to Stripe yet. Please install the Dub Stripe app in settings to create a discount.", + ); } const settings = stripeIntegrationSettingsSchema.parse( @@ -50,6 +52,23 @@ async function requireInstalledIntegration( }; } +function isStripePermissionsError(error: { + code?: string; + message?: string; + raw?: { message?: string }; +}): boolean { + const errorMessage = error.raw?.message || error.message; + + return ( + error.code === "more_permissions_required_for_application" || + Boolean( + errorMessage?.includes( + "This application does not have the required permissions", + ), + ) + ); +} + function createStripeDiscountProvider() { const getCoupon = async ({ couponId, @@ -148,9 +167,15 @@ function createStripeDiscountProvider() { }); if (!discount.couponId) { - throw new Error(`couponId not found for discount ${discount.id}.`); + throw new DiscountProviderError( + "stripe", + "COUPON_NOT_FOUND", + "The coupon ID for this discount was not found. Please check the discount configuration and try again.", + ); } + const couponId = discount.couponId; + let attempt = 0; let currentCode = code; @@ -158,7 +183,7 @@ function createStripeDiscountProvider() { try { return await stripeApp.promotionCodes.create( { - coupon: discount.couponId, + coupon: couponId, code: currentCode.toUpperCase(), restrictions: { first_time_transaction: @@ -173,31 +198,64 @@ function createStripeDiscountProvider() { const errorMessage = error.raw?.message || error.message; const isDuplicateError = errorMessage?.includes("already exists"); - if (!isDuplicateError) { - throw error; + if (isDuplicateError) { + if (!shouldRetry) { + throw new DiscountProviderError( + "stripe", + "DISCOUNT_ALREADY_EXISTS", + `The discount code ${currentCode} is already in use. Please choose a different code.`, + ); + } + + attempt++; + + if (attempt >= MAX_ATTEMPTS) { + throw new DiscountProviderError( + "stripe", + "CREATE_FAILED", + `Failed to create a unique discount code after ${MAX_ATTEMPTS} attempts. Please try again.`, + ); + } + + const newCode = `${currentCode}${nanoid(2)}`; + + console.warn( + `Discount code "${currentCode}" already exists. Retrying with "${newCode}" (attempt ${attempt}/${MAX_ATTEMPTS}).`, + ); + + currentCode = newCode; + continue; } - if (!shouldRetry) { - throw error; + if (isStripePermissionsError(error)) { + throw new DiscountProviderError( + "stripe", + "PERMISSIONS_REQUIRED", + errorMessage, + ); } - attempt++; - - if (attempt >= MAX_ATTEMPTS) { - throw error; + if (error.code === "resource_missing") { + throw new DiscountProviderError( + "stripe", + "COUPON_NOT_FOUND", + `The coupon ID you provided (${couponId}) was not found in your Stripe account. Please check the coupon ID and try again.`, + ); } - const newCode = `${currentCode}${nanoid(2)}`; - - console.warn( - `Discount code "${currentCode}" already exists. Retrying with "${newCode}" (attempt ${attempt}/${MAX_ATTEMPTS}).`, + throw new DiscountProviderError( + "stripe", + "CREATE_FAILED", + errorMessage || "Failed to create Stripe discount code.", ); - - currentCode = newCode; } } - throw new Error("Failed to create Stripe discount code."); + throw new DiscountProviderError( + "stripe", + "CREATE_FAILED", + "Failed to create Stripe discount code.", + ); }; const disableDiscountCode = async ({ @@ -213,43 +271,55 @@ function createStripeDiscountProvider() { mode: settings.stripeMode, }); - const promotionCodes = await stripeApp.promotionCodes.list( - { - code, - limit: 1, - }, - { - stripeAccount: workspace.stripeConnectId!, - }, - ); - - if (promotionCodes.data.length === 0) { - console.error( - `Stripe promotion code ${code} not found (stripeConnectId=${workspace.stripeConnectId}).`, + try { + const promotionCodes = await stripeApp.promotionCodes.list( + { + code, + limit: 1, + }, + { + stripeAccount: workspace.stripeConnectId!, + }, ); - return; - } - const promotionCode = promotionCodes.data[0]; + if (promotionCodes.data.length === 0) { + console.error( + `Stripe promotion code ${code} not found (stripeConnectId=${workspace.stripeConnectId}).`, + ); + return; + } - await stripeApp.promotionCodes.update( - promotionCode.id, - { - active: false, - }, - { - stripeAccount: workspace.stripeConnectId!, - }, - ); + const promotionCode = promotionCodes.data[0]; - console.info( - `Disabled Stripe promotion code ${promotionCode.code} (id=${promotionCode.id}, stripeConnectId=${workspace.stripeConnectId}).`, - ); + await stripeApp.promotionCodes.update( + promotionCode.id, + { + active: false, + }, + { + stripeAccount: workspace.stripeConnectId!, + }, + ); + + console.info( + `Disabled Stripe promotion code ${promotionCode.code} (id=${promotionCode.id}, stripeConnectId=${workspace.stripeConnectId}).`, + ); - return promotionCode; + return promotionCode; + } catch (error: any) { + if (isStripePermissionsError(error)) { + throw new DiscountProviderError( + "stripe", + "PERMISSIONS_REQUIRED", + error.raw?.message || error.message, + ); + } + + throw error; + } }; - const assertDiscountIntegrationAvailable = async ({ + const assertDiscountIntegration = async ({ workspace, }: { workspace: Pick; @@ -262,7 +332,7 @@ function createStripeDiscountProvider() { createCoupon, createDiscountCode, disableDiscountCode, - assertDiscountIntegrationAvailable, + assertDiscountIntegration, }; } diff --git a/apps/web/ui/partners/constants.ts b/apps/web/ui/partners/constants.ts index a59782af0dc..8fd7b5607aa 100644 --- a/apps/web/ui/partners/constants.ts +++ b/apps/web/ui/partners/constants.ts @@ -17,9 +17,19 @@ export const ERROR_MAP: Record< ctaLabel: "Install Shopify app", ctaUrl: "https://apps.shopify.com/dub-conversion-tracking", }, + SHOPIFY_RECONNECT_REQUIRED: { + title: "Shopify reconnection required", + ctaLabel: "Reconnect Shopify app", + ctaUrl: "https://apps.shopify.com/dub-conversion-tracking", + }, SHOPIFY_APP_UPGRADE_REQUIRED: { title: "Shopify app upgrade required", ctaLabel: "Review permissions", ctaUrl: "https://apps.shopify.com/dub-conversion-tracking", }, + STRIPE_RECONNECT_REQUIRED: { + title: "Stripe reconnection required", + ctaLabel: "Reconnect Stripe app", + ctaUrl: "https://marketplace.stripe.com/apps/dub-conversions", + }, };