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
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 2 additions & 15 deletions apps/web/app/(ee)/api/cron/discount-codes/create/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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" });
}

Expand Down
8 changes: 5 additions & 3 deletions apps/web/app/(ee)/api/cron/discount-codes/disable/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 13 additions & 7 deletions apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/actions/partners/create-discount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const createDiscountAction = authActionClient
});
}
} else if (provider === DiscountProvider.shopify) {
await discountProvider.assertDiscountIntegrationAvailable({
await discountProvider.assertDiscountIntegration({
workspace,
});
}
Expand Down
33 changes: 6 additions & 27 deletions apps/web/lib/discounts/create-discount-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,33 +41,12 @@ export async function createDiscountCode({

const discountProvider = getDiscountProvider(discount.provider);

let externalDiscountCode: Awaited<
ReturnType<typeof discountProvider.createDiscountCode>
>;

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({
Expand Down
77 changes: 70 additions & 7 deletions apps/web/lib/discounts/discount-error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading