diff --git a/apps/web/app/[domain]/banned/page.tsx b/apps/web/app/[domain]/banned/page.tsx
index 09d2319915a..9c67f90270d 100644
--- a/apps/web/app/[domain]/banned/page.tsx
+++ b/apps/web/app/[domain]/banned/page.tsx
@@ -24,7 +24,7 @@ export function generateStaticParams() {
return [];
}
-export default async function BannedPage() {
+export default function BannedPage() {
return (
diff --git a/apps/web/app/[domain]/notfound/page.tsx b/apps/web/app/[domain]/notfound/page.tsx
index 6e82cd53060..3c5eb02a618 100644
--- a/apps/web/app/[domain]/notfound/page.tsx
+++ b/apps/web/app/[domain]/notfound/page.tsx
@@ -4,8 +4,10 @@ import { CTA } from "@/ui/placeholders/cta";
import { FeaturesSection } from "@/ui/placeholders/features-section";
import { Hero } from "@/ui/placeholders/hero";
import { LearnMoreButton } from "@/ui/placeholders/learn-more-button";
+import { prisma } from "@dub/prisma";
import { GlobeSearch } from "@dub/ui";
import { cn, constructMetadata } from "@dub/utils";
+import { redirect } from "next/navigation";
export const revalidate = false; // cache indefinitely
@@ -26,7 +28,20 @@ export function generateStaticParams() {
return [];
}
-export default async function NotFoundLinkPage() {
+export default async function NotFoundLinkPage(props: {
+ params: Promise<{ domain: string }>;
+}) {
+ const { domain } = await props.params;
+ const domainData = await prisma.domain.findUnique({
+ where: {
+ slug: domain,
+ },
+ });
+
+ if (domainData?.notFoundUrl) {
+ redirect(domainData.notFoundUrl);
+ }
+
return (
diff --git a/apps/web/app/api/domains/[domain]/route.ts b/apps/web/app/api/domains/[domain]/route.ts
index e85f966360b..fe9e45d78a6 100644
--- a/apps/web/app/api/domains/[domain]/route.ts
+++ b/apps/web/app/api/domains/[domain]/route.ts
@@ -16,7 +16,7 @@ import { prisma } from "@dub/prisma";
import { Prisma } from "@dub/prisma/client";
import { combineWords, nanoid, R2_URL } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
-import { revalidateTag } from "next/cache";
+import { revalidatePath, revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
import * as z from "zod/v4";
@@ -233,13 +233,22 @@ export const PATCH = withWorkspace(
]);
}
- // invalidate notfound cache
+ // invalidate static / isr cached for notfound links
if (
- (notFoundUrl !== undefined &&
- notFoundUrl !== existingDomain.notFoundUrl) ||
- (expiredUrl !== undefined && expiredUrl !== existingDomain.expiredUrl)
+ notFoundUrl !== undefined &&
+ notFoundUrl !== existingDomain.notFoundUrl
) {
- revalidateTag(`notfound:${domain.toLowerCase()}`);
+ revalidateTag(`static:${domain.toLowerCase()}`);
+ revalidatePath(`/${domain.toLowerCase()}/notfound`);
+ }
+
+ // invalidate static / isr cached for expired links
+ if (
+ expiredUrl !== undefined &&
+ expiredUrl !== existingDomain.expiredUrl
+ ) {
+ revalidateTag(`static:${domain.toLowerCase()}`);
+ revalidatePath(`/${domain.toLowerCase()}/expired`);
}
// invalidate wellknown cache if any of the wellknown files have changed
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
index d7ade26b1d6..72d2c034a85 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
@@ -677,7 +677,11 @@ function CreateCommissionSheetContent({
{formatDate(inv.createdAt)}
- {inv.dubCommissionId && (
+ {inv.refunded ? (
+
+ Refunded
+
+ ) : inv.dubCommissionId ? (
Already imported
- )}
+ ) : null}
invoice.id === saleEvent.invoice_id,
+ )?.refunded && {
+ status: "refunded",
+ }),
user,
context: {
customer: { country: customer.country },
diff --git a/apps/web/lib/api/customers/get-customer-stripe-invoices.ts b/apps/web/lib/api/customers/get-customer-stripe-invoices.ts
index 4bd0f65e6e6..837b8c50148 100644
--- a/apps/web/lib/api/customers/get-customer-stripe-invoices.ts
+++ b/apps/web/lib/api/customers/get-customer-stripe-invoices.ts
@@ -1,11 +1,25 @@
import { stripeAppClient } from "@/lib/stripe";
import { StripeCustomerInvoiceSchema } from "@/lib/zod/schemas/customers";
import { prisma } from "@dub/prisma";
+import Stripe from "stripe";
const stripe = stripeAppClient({
...(process.env.VERCEL_ENV && { mode: "live" }),
});
+type ExpandedStripeInvoice = Stripe.Invoice & {
+ id: string;
+ payments?: {
+ data?: Array<{
+ payment?: {
+ type?: "charge" | "payment_intent" | "payment_record";
+ charge?: string | Stripe.Charge | null;
+ payment_intent?: string | Stripe.PaymentIntent | null;
+ };
+ }>;
+ };
+};
+
export async function getCustomerStripeInvoices({
stripeCustomerId,
stripeConnectId,
@@ -20,20 +34,37 @@ export async function getCustomerStripeInvoices({
customer: stripeCustomerId,
status: "paid",
limit: 100,
+ expand: ["data.payments.data.payment"],
},
{
stripeAccount: stripeConnectId,
},
);
- const validInvoices = data.filter(
- (invoice): invoice is (typeof data)[number] & { id: string } =>
- typeof invoice.id === "string",
- );
+ const invoices = data.filter(
+ (invoice) => invoice.id,
+ ) as ExpandedStripeInvoice[];
+
+ let charges: Stripe.Charge[] = [];
+ try {
+ const res = await stripe.charges.list(
+ {
+ limit: 100,
+ customer: stripeCustomerId,
+ },
+ {
+ stripeAccount: stripeConnectId,
+ },
+ );
+ charges = res.data;
+ } catch (error) {
+ // Stripe integration might be outdated and don't have charges.read permission
+ console.warn(error);
+ }
const commissions = await prisma.commission.findMany({
where: {
invoiceId: {
- in: validInvoices.map((invoice) => invoice.id),
+ in: invoices.map((invoice) => invoice.id),
},
programId: programId,
},
@@ -41,19 +72,54 @@ export async function getCustomerStripeInvoices({
const invoiceIdCommissionIdMap = commissions.reduce(
(acc, commission) => {
- acc[commission.invoiceId!] = commission.id;
+ if (commission.invoiceId) {
+ acc[commission.invoiceId] = commission.id;
+ }
return acc;
},
{} as Record,
);
- const stripeCustomerInvoices = validInvoices.map((invoice) =>
+ const processInvoice = (invoice: ExpandedStripeInvoice) => {
+ const { payments, ...rest } = invoice;
+ if (!payments || !payments.data || !payments.data.length) {
+ return {
+ refunded: false,
+ metadata: rest,
+ };
+ }
+
+ for (const payment of payments.data) {
+ if (
+ payment.payment?.type === "payment_intent" &&
+ payment.payment?.payment_intent
+ ) {
+ const charge = charges.find(
+ (charge) => charge.payment_intent === payment.payment?.payment_intent,
+ );
+ if (charge?.refunded || (charge?.amount_refunded ?? 0) > 0) {
+ return {
+ refunded: true,
+ metadata: rest,
+ };
+ }
+ }
+ }
+
+ return {
+ refunded: false,
+ metadata: rest,
+ };
+ };
+
+ const stripeCustomerInvoices = invoices.map((invoice) =>
StripeCustomerInvoiceSchema.parse({
id: invoice.id,
amount: invoice.amount_paid,
createdAt: new Date(invoice.created * 1000),
- metadata: invoice,
+ refunded: processInvoice(invoice).refunded,
dubCommissionId: invoiceIdCommissionIdMap[invoice.id],
+ metadata: processInvoice(invoice).metadata,
}),
);
diff --git a/apps/web/lib/api/links/cache.ts b/apps/web/lib/api/links/cache.ts
index 081e483f552..cdb40a5a885 100644
--- a/apps/web/lib/api/links/cache.ts
+++ b/apps/web/lib/api/links/cache.ts
@@ -41,7 +41,7 @@ class LinkCache {
const redisLink = formatRedisLink(link);
const cacheKey = this._createKey({ domain: link.domain, key: link.key });
pipeline.set(cacheKey, redisLink, { ex: REDIS_CACHE_EXPIRATION });
- revalidateTag(`notfound:${link.domain.toLowerCase()}:${link.key}`);
+ revalidateTag(`static:${link.domain.toLowerCase()}:${link.key}`);
});
return await pipeline.exec();
@@ -53,7 +53,7 @@ class LinkCache {
// Update LRU cache immediately to prevent stale reads
linkLRUCache.set(cacheKey, redisLink);
- revalidateTag(`notfound:${link.domain.toLowerCase()}:${link.key}`);
+ revalidateTag(`static:${link.domain.toLowerCase()}:${link.key}`);
await Promise.all([
redisGlobal.set(cacheKey, redisLink, {
@@ -170,12 +170,15 @@ class LinkCache {
return caseSensitive ? cacheKey : cacheKey.toLowerCase();
}
- _createNotFoundCacheKeys({ domain, key }: Pick) {
+ _createStaticPagesCacheKeys({
+ domain,
+ key,
+ }: Pick) {
domain = domain.toLowerCase();
// here we set 2 cache tags to invalidate the cache:
- // 1. notfound:${domain}:${key} - for the specific not found link
- // 2. notfound:${domain} - scope all links under the domain (for easy purging in PATCH /domains/:domain)
- return `notfound:${domain}:${key},notfound:${domain}`;
+ // 1. static:${domain}:${key} - for the specific static page
+ // 2. static:${domain} - scope all links under the domain (for easy purging in PATCH /domains/:domain)
+ return `static:${domain}:${key},static:${domain}`;
}
// Vercel cache reads are 10x cheaper than writes, so to invalidate the cache
diff --git a/apps/web/lib/middleware/link.ts b/apps/web/lib/middleware/link.ts
index 0a2d9f45030..dde2f48d6e4 100644
--- a/apps/web/lib/middleware/link.ts
+++ b/apps/web/lib/middleware/link.ts
@@ -29,7 +29,6 @@ import { createResponseWithCookies } from "./utils/create-response-with-cookies"
import { detectBot } from "./utils/detect-bot";
import { getFinalUrl } from "./utils/get-final-url";
import { getIdentityHash } from "./utils/get-identity-hash";
-import { handleNotFoundLink } from "./utils/handle-not-found-link";
import { isIosAppStoreUrl } from "./utils/is-ios-app-store-url";
import { isSingularTrackingUrl } from "./utils/is-singular-tracking-url";
import { isSupportedCustomURIScheme } from "./utils/is-supported-custom-uri-scheme";
@@ -62,6 +61,14 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
key = "_root";
}
+ const STATIC_PAGES_CACHE_HEADERS = {
+ "Vercel-CDN-Cache-Control": "public, s-maxage=86400",
+ "Vercel-Cache-Tag": linkCache._createStaticPagesCacheKeys({
+ domain,
+ key: originalKey || "_root",
+ }),
+ };
+
// we don't support .php links (too much bot traffic)
// hence we redirect to the root domain and add `dub-no-track` header to avoid tracking bot traffic
if (isUnsupportedKey(key)) {
@@ -88,7 +95,12 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
return await crawlBitly(req);
}
- return await handleNotFoundLink(req);
+ return NextResponse.rewrite(new URL(`/${domain}/notfound`, req.url), {
+ headers: {
+ ...DUB_HEADERS,
+ ...STATIC_PAGES_CACHE_HEADERS,
+ },
+ });
}
isPartnerLink = Boolean(linkData.programId && linkData.partnerId);
@@ -198,14 +210,20 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
return NextResponse.rewrite(new URL(`/${domain}/banned`, req.url), {
headers: {
...DUB_HEADERS,
- ...(!shouldIndex && { "X-Robots-Tag": "googlebot: noindex" }),
+ ...STATIC_PAGES_CACHE_HEADERS,
+ "X-Robots-Tag": "googlebot: noindex",
},
});
}
// handle disabled links
if (disabledAt) {
- return await handleNotFoundLink(req);
+ return NextResponse.rewrite(new URL(`/${domain}/notfound`, req.url), {
+ headers: {
+ ...DUB_HEADERS,
+ ...STATIC_PAGES_CACHE_HEADERS,
+ },
+ });
}
// if the link has expired
@@ -222,6 +240,7 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
return NextResponse.rewrite(new URL(`/${domain}/expired`, req.url), {
headers: {
...DUB_HEADERS,
+ ...STATIC_PAGES_CACHE_HEADERS,
...(!shouldIndex && { "X-Robots-Tag": "googlebot: noindex" }),
},
});
@@ -278,18 +297,11 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
{
headers: {
...DUB_HEADERS,
+ ...STATIC_PAGES_CACHE_HEADERS,
...(!shouldIndex && { "X-Robots-Tag": "googlebot: noindex" }),
},
},
);
- rewriteResponse.headers.set(
- "Vercel-CDN-Cache-Control",
- "public, s-maxage=86400",
- );
- rewriteResponse.headers.set(
- "Vercel-Cache-Tag",
- linkCache._createNotFoundCacheKeys({ domain, key: "_root" }), // set cache tag for root domain link
- );
return createResponseWithCookies(rewriteResponse, cookieData);
}
diff --git a/apps/web/lib/middleware/utils/crawl-bitly.ts b/apps/web/lib/middleware/utils/crawl-bitly.ts
index f687f7ca07a..de2d6af5cf5 100644
--- a/apps/web/lib/middleware/utils/crawl-bitly.ts
+++ b/apps/web/lib/middleware/utils/crawl-bitly.ts
@@ -1,19 +1,70 @@
+import { createId } from "@/lib/api/create-id";
import { linkCache } from "@/lib/api/links/cache";
-import { redis } from "@/lib/upstash";
-import { DUB_HEADERS } from "@dub/utils";
+import { encodeKeyIfCaseSensitive } from "@/lib/api/links/case-sensitivity";
+import { recordLink } from "@/lib/tinybird";
+import { prisma } from "@dub/prisma";
+import {
+ DUB_HEADERS,
+ getUrlFromStringIfValid,
+ linkConstructorSimple,
+} from "@dub/utils";
+import { waitUntil } from "@vercel/functions";
import { NextRequest, NextResponse } from "next/server";
import { parse } from "./parse";
+const BUFFER_WORKSPACE_ID = "cm05wnnpo000711ztj05wwdbu";
+const BUFFER_USER_ID = "cm05wnd49000411ztg2xbup0i";
+const BUFFER_FOLDER_ID = "fold_LIZsdjTgFVbQVGYSUmYAi5vT";
+const BUFFER_BITLY_API_KEY = process.env.BUFFER_BITLY_API_KEY;
+
export const crawlBitly = async (req: NextRequest) => {
- const { domain, fullKey } = parse(req);
+ const { domain, fullKey: key } = parse(req);
- // bitly doesn't support the following characters: ` ~ , . < > ; ‘ : “ / \ [ ] ^ { } ( ) = + ! * @ & $ £ ? % # |
+ // bitly doesn't support the following characters: ` ~ , . < > ; ‘ : " / \ [ ] ^ { } ( ) = + ! * @ & $ £ ? % # |
// @see: https://support.bitly.com/hc/en-us/articles/360030780892-What-characters-are-supported-when-customizing-links
const invalidBitlyKeyRegex = /[`~,.<>;':"/\\[\]^{}()=+!*@&$£?%#|]/;
- if (fullKey && !invalidBitlyKeyRegex.test(fullKey)) {
- const link = await fetchBitlyLink({ domain, key: fullKey });
+ if (key && !invalidBitlyKeyRegex.test(key)) {
+ const link = await fetchBitlyLink({ domain, key });
if (link) {
+ const sanitizedUrl = getUrlFromStringIfValid(link.long_url);
+ if (sanitizedUrl) {
+ console.log(
+ `[Bitly] Creating link on-demand: ${domain}/${key} (createdAt: ${link.created_at})`,
+ );
+ const encodedKey = encodeKeyIfCaseSensitive({ domain, key });
+ waitUntil(
+ prisma.link
+ .create({
+ data: {
+ id: createId({ prefix: "link_" }),
+ domain,
+ key: encodedKey,
+ url: sanitizedUrl,
+ shortLink: linkConstructorSimple({ domain, key: encodedKey }),
+ projectId: BUFFER_WORKSPACE_ID,
+ userId: BUFFER_USER_ID,
+ folderId: BUFFER_FOLDER_ID,
+ createdAt: new Date(link.created_at),
+ },
+ })
+ .then((data) =>
+ Promise.allSettled([
+ // console log outputs
+ recordLink(data),
+ prisma.project.update({
+ where: {
+ id: BUFFER_WORKSPACE_ID,
+ },
+ data: {
+ linksUsage: { increment: 1 },
+ },
+ }),
+ ]),
+ ),
+ );
+ }
+
return NextResponse.redirect(link.long_url, {
headers: DUB_HEADERS,
status: 302,
@@ -25,17 +76,15 @@ export const crawlBitly = async (req: NextRequest) => {
headers: {
...DUB_HEADERS,
"Vercel-CDN-Cache-Control": "public, s-maxage=86400",
- "Vercel-Cache-Tag": linkCache._createNotFoundCacheKeys({
+ "Vercel-Cache-Tag": linkCache._createStaticPagesCacheKeys({
domain,
- key: fullKey,
+ key,
}),
},
status: 302,
});
};
-const BUFFER_WORKSPACE_ID = "cm05wnnpo000711ztj05wwdbu";
-
async function fetchBitlyLink({
domain,
key,
@@ -43,20 +92,11 @@ async function fetchBitlyLink({
domain: string;
key: string;
}) {
- const apiKey = await redis.get(`import:bitly:${BUFFER_WORKSPACE_ID}`);
-
- if (!apiKey) {
- console.error(
- `[Bitly] No API key found for workspace ${BUFFER_WORKSPACE_ID}`,
- );
- return null;
- }
-
const response = await fetch(
`https://api-ssl.bitly.com/v4/bitlinks/${domain}/${key}`,
{
headers: {
- Authorization: `Bearer ${apiKey}`,
+ Authorization: `Bearer ${BUFFER_BITLY_API_KEY}`,
},
},
);
diff --git a/apps/web/lib/middleware/utils/handle-not-found-link.ts b/apps/web/lib/middleware/utils/handle-not-found-link.ts
deleted file mode 100644
index 12ddeb19529..00000000000
--- a/apps/web/lib/middleware/utils/handle-not-found-link.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { linkCache } from "@/lib/api/links/cache";
-import { getDomainViaEdge } from "@/lib/planetscale/get-domain-via-edge";
-import { DUB_HEADERS } from "@dub/utils";
-import { NextRequest, NextResponse } from "next/server";
-import { parse } from "./parse";
-
-export const handleNotFoundLink = async (req: NextRequest) => {
- const { domain, fullKey } = parse(req);
-
- let response: NextResponse;
- // check if domain has notFoundUrl configured
- const domainData = await getDomainViaEdge(domain);
- if (domainData?.notFoundUrl) {
- response = NextResponse.redirect(domainData.notFoundUrl, {
- headers: {
- ...DUB_HEADERS,
- "X-Robots-Tag": "googlebot: noindex",
- // pass the Referer [sic] value to the not found URL
- Referer: req.url,
- },
- status: 302,
- });
- } else {
- response = NextResponse.rewrite(new URL(`/${domain}/notfound`, req.url), {
- headers: DUB_HEADERS,
- });
- }
- response.headers.set("Vercel-CDN-Cache-Control", "public, s-maxage=86400");
- response.headers.set(
- "Vercel-Cache-Tag",
- linkCache._createNotFoundCacheKeys({ domain, key: fullKey }),
- );
- return response;
-};
diff --git a/apps/web/lib/zod/schemas/customers.ts b/apps/web/lib/zod/schemas/customers.ts
index aeb02e6e7f1..36ac2b377ab 100644
--- a/apps/web/lib/zod/schemas/customers.ts
+++ b/apps/web/lib/zod/schemas/customers.ts
@@ -213,8 +213,9 @@ export const StripeCustomerInvoiceSchema = z.object({
id: z.string(),
amount: z.number(),
createdAt: z.date(),
- metadata: z.any(),
+ refunded: z.boolean(),
dubCommissionId: z.string().nullish(),
+ metadata: z.any(),
});
export const CUSTOMER_EXPORT_COLUMNS = [
diff --git a/apps/web/tests/redirects/index.test.ts b/apps/web/tests/redirects/index.test.ts
index 0fe6a5906cd..94379fb06c5 100644
--- a/apps/web/tests/redirects/index.test.ts
+++ b/apps/web/tests/redirects/index.test.ts
@@ -82,9 +82,12 @@ describe.runIf(env.CI)("Link Redirects", async () => {
test("disabled link", async () => {
const response = await fetch(`${h.baseUrl}/disabled`, fetchOptions);
- expect(response.headers.get("location")).toBe("https://dub.co/");
- expect(response.headers.get("x-powered-by")).toBe(poweredBy);
- expect(response.status).toBe(302);
+ // Special case for disabled/notfound links since we're using redirect() from next/navigation:
+ // - doesn't support x-powered-by header
+ // - uses 307 status code instead of 302
+ // This is the same as case-sensitive (incorrect) key test below.
+ expect(response.headers.get("location")).toBe("https://dub.co/links");
+ expect(response.status).toBe(307);
});
test("with slash", async () => {
@@ -281,9 +284,8 @@ describe.runIf(env.CI)("Link Redirects", async () => {
fetchOptions,
);
- expect(response.headers.get("location")).toBe("https://dub.co/");
- expect(response.headers.get("x-powered-by")).toBe(poweredBy);
- expect(response.status).toBe(302);
+ expect(response.headers.get("location")).toBe("https://dub.co/links");
+ expect(response.status).toBe(307);
});
test("with case-sensitive key (and dub_id)", async () => {
diff --git a/packages/stripe-app/package.json b/packages/stripe-app/package.json
index 64efaca39da..e10825cb1e6 100644
--- a/packages/stripe-app/package.json
+++ b/packages/stripe-app/package.json
@@ -1,7 +1,7 @@
{
"name": "com.example.dub",
- "version": "0.0.5",
- "description": "Dub Conversions",
+ "version": "0.0.19",
+ "description": "Dub Partners",
"private": true,
"license": "~~proprietary~~",
"dependencies": {
diff --git a/packages/stripe-app/stripe-app.json b/packages/stripe-app/stripe-app.json
index 1dd4df826e3..fc0284b5f29 100644
--- a/packages/stripe-app/stripe-app.json
+++ b/packages/stripe-app/stripe-app.json
@@ -1,6 +1,6 @@
{
"id": "dub.co",
- "version": "0.0.18",
+ "version": "0.0.19",
"name": "Dub Partners",
"icon": "./stripe-icon.png",
"permissions": [
@@ -16,6 +16,10 @@
"permission": "invoice_read",
"purpose": "Allows Dub to read invoice information."
},
+ {
+ "permission": "charge_read",
+ "purpose": "Allows Dub to read charges to detect refunded payments."
+ },
{
"permission": "checkout_session_read",
"purpose": "Allows Dub to read checkout session information."
@@ -57,7 +61,6 @@
"purpose": "Allows Dub to read promotion codes for an account."
}
],
- "connect_permissions": null,
"ui_extension": {
"views": [
{
@@ -77,13 +80,13 @@
}
},
"post_install_action": {
- "type": "settings"
+ "type": "settings",
+ "url": ""
},
"allowed_redirect_uris": [
"https://app.dub.co/api/stripe/integration/callback",
"https://preview.dub.co/api/stripe/integration/callback"
],
"stripe_api_access_type": "oauth",
- "distribution_type": "public",
- "sandbox_install_compatible": true
+ "distribution_type": "public"
}
\ No newline at end of file