From 9b217e5424fae5f13665f4a98fa485f092c553db Mon Sep 17 00:00:00 2001 From: Andy Liu <59021924+andy-liuu@users.noreply.github.com> Date: Mon, 28 Oct 2024 13:50:03 -0400 Subject: [PATCH] Add billing api helper to remix package for updating a usage billing plan's capped amount (#1691) * initial commit: remix redirect wrapper and test * run pnpm build-docs * add changeset * change SUBSCRIPTION_LINE_ITEM_ID example to real example + pnpm build-docs --- .changeset/slow-gorillas-judge.md | 19 + .../docs/generated/generated_docs_data.json | 194 +++++++++- .../generated/generated_static_pages.json | 18 +- .../server/authenticate/admin/authenticate.ts | 6 + .../admin/billing/__tests__/mock-responses.ts | 33 ++ ...e-usage-subscription-capped-amount.test.ts | 342 ++++++++++++++++++ .../authenticate/admin/billing/helpers.ts | 42 +++ .../authenticate/admin/billing/index.ts | 1 + .../authenticate/admin/billing/request.ts | 42 +-- .../authenticate/admin/billing/types.ts | 76 ++++ ...update-usage-subscription-capped-amount.ts | 55 +++ 11 files changed, 775 insertions(+), 53 deletions(-) create mode 100644 .changeset/slow-gorillas-judge.md create mode 100644 packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/__tests__/update-usage-subscription-capped-amount.test.ts create mode 100644 packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/helpers.ts create mode 100644 packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/update-usage-subscription-capped-amount.ts diff --git a/.changeset/slow-gorillas-judge.md b/.changeset/slow-gorillas-judge.md new file mode 100644 index 0000000000..2f2f38559c --- /dev/null +++ b/.changeset/slow-gorillas-judge.md @@ -0,0 +1,19 @@ +--- +'@shopify/shopify-app-remix': minor +--- + +Adds API to update the capped amount for a usage billing plan. + +A new billing helper function has been added to update the capped amount for a usage billing plan. This function redirects to a confirmation page where the merchant can confirm the update. + +```ts +await billing.updateUsageCappedAmount({ + subscriptionLineItemId: "gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1", + cappedAmount: { + amount: 10, + currencyCode: "USD" + }, +}); +``` + +Learn more about [App Billing](https://shopify.dev/docs/apps/launch/billing/subscription-billing). \ No newline at end of file diff --git a/packages/apps/shopify-app-remix/docs/generated/generated_docs_data.json b/packages/apps/shopify-app-remix/docs/generated/generated_docs_data.json index d818d3a6e6..7f9c20ed9c 100644 --- a/packages/apps/shopify-app-remix/docs/generated/generated_docs_data.json +++ b/packages/apps/shopify-app-remix/docs/generated/generated_docs_data.json @@ -592,9 +592,32 @@ ] } ] + }, + { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "syntaxKind": "PropertySignature", + "name": "updateUsageCappedAmount", + "value": "(options: UpdateUsageCappedAmountOptions) => Promise", + "description": "Updates the capped amount for a usage billing plan.", + "examples": [ + { + "title": "Updating the capped amount for a usage billing plan", + "description": "Update the capped amount for the usage billing plan specified by `subscriptionLineItemId`.", + "tabs": [ + { + "code": "import { ActionFunctionArgs } from \"@remix-run/node\";\nimport { authenticate } from \"../shopify.server\";\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { billing } = await authenticate.admin(request);\n\n await billing.updateUsageCappedAmount({\n subscriptionLineItemId: \"gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1\",\n cappedAmount: {\n amount: 10,\n currencyCode: \"USD\"\n },\n });\n\n // App logic\n};", + "title": "/app/routes/**\\/*.ts" + }, + { + "code": "import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n\nexport const USAGE_PLAN = 'Usage subscription';\n\nconst shopify = shopifyApp({\n // ...etc\n billing: {\n [USAGE_PLAN]: {\n lineItems: [\n {\n amount: 5,\n currencyCode: 'USD',\n interval: BillingInterval.Usage,\n terms: \"Usage based\"\n }\n ],\n },\n }\n});\nexport default shopify;\nexport const authenticate = shopify.authenticate;", + "title": "shopify.server.ts" + } + ] + } + ] } ], - "value": "export interface BillingContext {\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Requesting billing right away.\n * Call `billing.request` in the `onFailure` callback to immediately redirect to the Shopify page to request payment.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * isTest: true,\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Redirect to a plan selection page.\n * When the app has multiple plans, create a page in your App that allows the merchant to select a plan. If a merchant does not have the required plan you can redirect them to page in your app to select one.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs, redirect } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN, ANNUAL_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN, ANNUAL_PLAN],\n * isTest: true,\n * onFailure: () => redirect('/select-plan'),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * console.log(`Shop is on ${subscription.name} (id ${subscription.id})`);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n require: (\n options: RequireBillingOptions,\n ) => Promise;\n\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Check what billing plans a merchant is subscribed to.\n * Use billing.check if you want to determine which plans are in use. Unlike `require`, `check` does not\n * throw an error if no active billing plans are present. \n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check({\n * plans: [MONTHLY_PLAN],\n * isTest: false,\n * });\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Check for payments without filtering.\n * Use billing.check to see if any payments exist for the store, regardless of whether it's a test or\n * matches one or more plans.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check();\n * // This will be true if any payment is found\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n */\n check: >(\n options?: Options,\n ) => Promise;\n\n /**\n * Requests payment for the plan.\n *\n * @returns Redirects to the confirmation URL for the payment.\n *\n * @example\n * Using a custom return URL.\n * Change where the merchant is returned to after approving the purchase using the `returnUrl` option.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * returnUrl: 'https://admin.shopify.com/store/my-store/apps/my-app/billing-page',\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Overriding plan settings.\n * Customize the plan for a merchant when requesting billing. Any fields from the plan can be overridden, as long as the billing interval for line items matches the config.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * trialDays: 14,\n * lineItems: [\n * {\n * interval: BillingInterval.Every30Days,\n * discount: { value: { percentage: 0.1 } },\n * },\n * ],\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n request: (options: RequestBillingOptions) => Promise;\n\n /**\n * Cancels an ongoing subscription, given its ID.\n *\n * @returns The cancelled subscription.\n *\n * @example\n * Cancelling a subscription.\n * Use the `billing.cancel` function to cancel an active subscription with the id returned from `billing.require`.\n * ```ts\n * // /app/routes/cancel-subscription.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * const cancelledSubscription = await billing.cancel({\n * subscriptionId: subscription.id,\n * isTest: true,\n * prorate: true,\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n cancel: (options: CancelBillingOptions) => Promise;\n\n /**\n * Creates a usage record for an app subscription.\n *\n * @returns Returns a usage record when one was created successfully.\n *\n * @example\n * Creating a usage record\n * Create a usage record for the active usage billing plan\n * ```ts\n * // /app/routes/create-usage-record.ts\n * import { ActionFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const action = async ({ request }: ActionFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n *\n * const chargeBilling = await billing.createUsageRecord({\n * description: \"Usage record for product creation\",\n * price: {\n * amount: 1,\n * currencyCode: \"USD\",\n * },\n * isTest: true,\n * });\n * console.log(chargeBilling);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const USAGE_PLAN = 'Usage subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [USAGE_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Usage,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n createUsageRecord: (\n options: CreateUsageRecordOptions,\n ) => Promise;\n}" + "value": "export interface BillingContext {\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Requesting billing right away.\n * Call `billing.request` in the `onFailure` callback to immediately redirect to the Shopify page to request payment.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * isTest: true,\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Redirect to a plan selection page.\n * When the app has multiple plans, create a page in your App that allows the merchant to select a plan. If a merchant does not have the required plan you can redirect them to page in your app to select one.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs, redirect } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN, ANNUAL_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN, ANNUAL_PLAN],\n * isTest: true,\n * onFailure: () => redirect('/select-plan'),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * console.log(`Shop is on ${subscription.name} (id ${subscription.id})`);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n require: (\n options: RequireBillingOptions,\n ) => Promise;\n\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Check what billing plans a merchant is subscribed to.\n * Use billing.check if you want to determine which plans are in use. Unlike `require`, `check` does not\n * throw an error if no active billing plans are present. \n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check({\n * plans: [MONTHLY_PLAN],\n * isTest: false,\n * });\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Check for payments without filtering.\n * Use billing.check to see if any payments exist for the store, regardless of whether it's a test or\n * matches one or more plans.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check();\n * // This will be true if any payment is found\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n */\n check: >(\n options?: Options,\n ) => Promise;\n\n /**\n * Requests payment for the plan.\n *\n * @returns Redirects to the confirmation URL for the payment.\n *\n * @example\n * Using a custom return URL.\n * Change where the merchant is returned to after approving the purchase using the `returnUrl` option.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * returnUrl: 'https://admin.shopify.com/store/my-store/apps/my-app/billing-page',\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Overriding plan settings.\n * Customize the plan for a merchant when requesting billing. Any fields from the plan can be overridden, as long as the billing interval for line items matches the config.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * trialDays: 14,\n * lineItems: [\n * {\n * interval: BillingInterval.Every30Days,\n * discount: { value: { percentage: 0.1 } },\n * },\n * ],\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n request: (options: RequestBillingOptions) => Promise;\n\n /**\n * Cancels an ongoing subscription, given its ID.\n *\n * @returns The cancelled subscription.\n *\n * @example\n * Cancelling a subscription.\n * Use the `billing.cancel` function to cancel an active subscription with the id returned from `billing.require`.\n * ```ts\n * // /app/routes/cancel-subscription.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * const cancelledSubscription = await billing.cancel({\n * subscriptionId: subscription.id,\n * isTest: true,\n * prorate: true,\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n cancel: (options: CancelBillingOptions) => Promise;\n\n /**\n * Creates a usage record for an app subscription.\n *\n * @returns Returns a usage record when one was created successfully.\n *\n * @example\n * Creating a usage record\n * Create a usage record for the active usage billing plan\n * ```ts\n * // /app/routes/create-usage-record.ts\n * import { ActionFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const action = async ({ request }: ActionFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n *\n * const chargeBilling = await billing.createUsageRecord({\n * description: \"Usage record for product creation\",\n * price: {\n * amount: 1,\n * currencyCode: \"USD\",\n * },\n * isTest: true,\n * });\n * console.log(chargeBilling);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const USAGE_PLAN = 'Usage subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [USAGE_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Usage,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n createUsageRecord: (\n options: CreateUsageRecordOptions,\n ) => Promise;\n\n /**\n * Updates the capped amount for a usage billing plan.\n *\n * @returns Redirects to a confirmation page to update the usage billing plan.\n *\n * @example\n * Updating the capped amount for a usage billing plan.\n * Update the capped amount for the usage billing plan specified by `subscriptionLineItemId`.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { ActionFunctionArgs } from \"@remix-run/node\";\n * import { authenticate } from \"../shopify.server\";\n *\n * export const action = async ({ request }: ActionFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n *\n * await billing.updateUsageCappedAmount({\n * subscriptionLineItemId: \"gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1\",\n * cappedAmount: {\n * amount: 10,\n * currencyCode: \"USD\"\n * },\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const USAGE_PLAN = 'Usage subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [USAGE_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Usage,\n * terms: \"Usage based\"\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n updateUsageCappedAmount: (\n options: UpdateUsageCappedAmountOptions,\n ) => Promise;\n}" }, "CancelBillingOptions": { "filePath": "src/server/authenticate/admin/billing/types.ts", @@ -757,6 +780,28 @@ ], "value": "export interface RequireBillingOptions\n extends Omit {\n /**\n * The plans to check for. Must be one of the values defined in the `billing` config option.\n */\n plans: (keyof Config['billing'])[];\n /**\n * How to handle the request if the shop doesn't have an active payment for any plan.\n */\n onFailure: (error: any) => Promise;\n}" }, + "UpdateUsageCappedAmountOptions": { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "name": "UpdateUsageCappedAmountOptions", + "description": "", + "members": [ + { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "syntaxKind": "PropertySignature", + "name": "cappedAmount", + "value": "{ amount: number; currencyCode: string; }", + "description": "The maximum charge for the usage billing plan." + }, + { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "syntaxKind": "PropertySignature", + "name": "subscriptionLineItemId", + "value": "string", + "description": "The subscription line item ID to update." + } + ], + "value": "export interface UpdateUsageCappedAmountOptions {\n /**\n * The subscription line item ID to update.\n */\n subscriptionLineItemId: string;\n /**\n * The maximum charge for the usage billing plan.\n */\n cappedAmount: {\n /**\n * The amount to update.\n */\n amount: number;\n /**\n * The currency code to update.\n */\n currencyCode: string;\n };\n}" + }, "EnsureCORSFunction": { "filePath": "src/server/authenticate/helpers/ensure-cors-headers.ts", "name": "EnsureCORSFunction", @@ -1435,6 +1480,29 @@ } } ] + }, + { + "title": "updateUsageCappedAmount", + "examples": [ + { + "description": "Update the capped amount for the usage billing plan specified by `subscriptionLineItemId`.", + "codeblock": { + "title": "Updating the capped amount for a usage billing plan", + "tabs": [ + { + "title": "/app/routes/**\\/*.ts", + "code": "import { ActionFunctionArgs } from \"@remix-run/node\";\nimport { authenticate } from \"../shopify.server\";\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { billing } = await authenticate.admin(request);\n\n await billing.updateUsageCappedAmount({\n subscriptionLineItemId: \"gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1\",\n cappedAmount: {\n amount: 10,\n currencyCode: \"USD\"\n },\n });\n\n // App logic\n};", + "language": "typescript" + }, + { + "title": "shopify.server.ts", + "code": "import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n\nexport const USAGE_PLAN = 'Usage subscription';\n\nconst shopify = shopifyApp({\n // ...etc\n billing: {\n [USAGE_PLAN]: {\n lineItems: [\n {\n amount: 5,\n currencyCode: 'USD',\n interval: BillingInterval.Usage,\n terms: \"Usage based\"\n }\n ],\n },\n }\n});\nexport default shopify;\nexport const authenticate = shopify.authenticate;", + "language": "typescript" + } + ] + } + } + ] } ] } @@ -1608,9 +1676,32 @@ ] } ] + }, + { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "syntaxKind": "PropertySignature", + "name": "updateUsageCappedAmount", + "value": "(options: UpdateUsageCappedAmountOptions) => Promise", + "description": "Updates the capped amount for a usage billing plan.", + "examples": [ + { + "title": "Updating the capped amount for a usage billing plan", + "description": "Update the capped amount for the usage billing plan specified by `subscriptionLineItemId`.", + "tabs": [ + { + "code": "import { ActionFunctionArgs } from \"@remix-run/node\";\nimport { authenticate } from \"../shopify.server\";\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { billing } = await authenticate.admin(request);\n\n await billing.updateUsageCappedAmount({\n subscriptionLineItemId: \"gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1\",\n cappedAmount: {\n amount: 10,\n currencyCode: \"USD\"\n },\n });\n\n // App logic\n};", + "title": "/app/routes/**\\/*.ts" + }, + { + "code": "import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n\nexport const USAGE_PLAN = 'Usage subscription';\n\nconst shopify = shopifyApp({\n // ...etc\n billing: {\n [USAGE_PLAN]: {\n lineItems: [\n {\n amount: 5,\n currencyCode: 'USD',\n interval: BillingInterval.Usage,\n terms: \"Usage based\"\n }\n ],\n },\n }\n});\nexport default shopify;\nexport const authenticate = shopify.authenticate;", + "title": "shopify.server.ts" + } + ] + } + ] } ], - "value": "export interface BillingContext {\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Requesting billing right away.\n * Call `billing.request` in the `onFailure` callback to immediately redirect to the Shopify page to request payment.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * isTest: true,\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Redirect to a plan selection page.\n * When the app has multiple plans, create a page in your App that allows the merchant to select a plan. If a merchant does not have the required plan you can redirect them to page in your app to select one.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs, redirect } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN, ANNUAL_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN, ANNUAL_PLAN],\n * isTest: true,\n * onFailure: () => redirect('/select-plan'),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * console.log(`Shop is on ${subscription.name} (id ${subscription.id})`);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n require: (\n options: RequireBillingOptions,\n ) => Promise;\n\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Check what billing plans a merchant is subscribed to.\n * Use billing.check if you want to determine which plans are in use. Unlike `require`, `check` does not\n * throw an error if no active billing plans are present. \n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check({\n * plans: [MONTHLY_PLAN],\n * isTest: false,\n * });\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Check for payments without filtering.\n * Use billing.check to see if any payments exist for the store, regardless of whether it's a test or\n * matches one or more plans.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check();\n * // This will be true if any payment is found\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n */\n check: >(\n options?: Options,\n ) => Promise;\n\n /**\n * Requests payment for the plan.\n *\n * @returns Redirects to the confirmation URL for the payment.\n *\n * @example\n * Using a custom return URL.\n * Change where the merchant is returned to after approving the purchase using the `returnUrl` option.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * returnUrl: 'https://admin.shopify.com/store/my-store/apps/my-app/billing-page',\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Overriding plan settings.\n * Customize the plan for a merchant when requesting billing. Any fields from the plan can be overridden, as long as the billing interval for line items matches the config.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * trialDays: 14,\n * lineItems: [\n * {\n * interval: BillingInterval.Every30Days,\n * discount: { value: { percentage: 0.1 } },\n * },\n * ],\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n request: (options: RequestBillingOptions) => Promise;\n\n /**\n * Cancels an ongoing subscription, given its ID.\n *\n * @returns The cancelled subscription.\n *\n * @example\n * Cancelling a subscription.\n * Use the `billing.cancel` function to cancel an active subscription with the id returned from `billing.require`.\n * ```ts\n * // /app/routes/cancel-subscription.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * const cancelledSubscription = await billing.cancel({\n * subscriptionId: subscription.id,\n * isTest: true,\n * prorate: true,\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n cancel: (options: CancelBillingOptions) => Promise;\n\n /**\n * Creates a usage record for an app subscription.\n *\n * @returns Returns a usage record when one was created successfully.\n *\n * @example\n * Creating a usage record\n * Create a usage record for the active usage billing plan\n * ```ts\n * // /app/routes/create-usage-record.ts\n * import { ActionFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const action = async ({ request }: ActionFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n *\n * const chargeBilling = await billing.createUsageRecord({\n * description: \"Usage record for product creation\",\n * price: {\n * amount: 1,\n * currencyCode: \"USD\",\n * },\n * isTest: true,\n * });\n * console.log(chargeBilling);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const USAGE_PLAN = 'Usage subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [USAGE_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Usage,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n createUsageRecord: (\n options: CreateUsageRecordOptions,\n ) => Promise;\n}" + "value": "export interface BillingContext {\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Requesting billing right away.\n * Call `billing.request` in the `onFailure` callback to immediately redirect to the Shopify page to request payment.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * isTest: true,\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Redirect to a plan selection page.\n * When the app has multiple plans, create a page in your App that allows the merchant to select a plan. If a merchant does not have the required plan you can redirect them to page in your app to select one.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs, redirect } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN, ANNUAL_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN, ANNUAL_PLAN],\n * isTest: true,\n * onFailure: () => redirect('/select-plan'),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * console.log(`Shop is on ${subscription.name} (id ${subscription.id})`);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n require: (\n options: RequireBillingOptions,\n ) => Promise;\n\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Check what billing plans a merchant is subscribed to.\n * Use billing.check if you want to determine which plans are in use. Unlike `require`, `check` does not\n * throw an error if no active billing plans are present. \n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check({\n * plans: [MONTHLY_PLAN],\n * isTest: false,\n * });\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Check for payments without filtering.\n * Use billing.check to see if any payments exist for the store, regardless of whether it's a test or\n * matches one or more plans.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check();\n * // This will be true if any payment is found\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n */\n check: >(\n options?: Options,\n ) => Promise;\n\n /**\n * Requests payment for the plan.\n *\n * @returns Redirects to the confirmation URL for the payment.\n *\n * @example\n * Using a custom return URL.\n * Change where the merchant is returned to after approving the purchase using the `returnUrl` option.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * returnUrl: 'https://admin.shopify.com/store/my-store/apps/my-app/billing-page',\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Overriding plan settings.\n * Customize the plan for a merchant when requesting billing. Any fields from the plan can be overridden, as long as the billing interval for line items matches the config.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * trialDays: 14,\n * lineItems: [\n * {\n * interval: BillingInterval.Every30Days,\n * discount: { value: { percentage: 0.1 } },\n * },\n * ],\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n request: (options: RequestBillingOptions) => Promise;\n\n /**\n * Cancels an ongoing subscription, given its ID.\n *\n * @returns The cancelled subscription.\n *\n * @example\n * Cancelling a subscription.\n * Use the `billing.cancel` function to cancel an active subscription with the id returned from `billing.require`.\n * ```ts\n * // /app/routes/cancel-subscription.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * const cancelledSubscription = await billing.cancel({\n * subscriptionId: subscription.id,\n * isTest: true,\n * prorate: true,\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n cancel: (options: CancelBillingOptions) => Promise;\n\n /**\n * Creates a usage record for an app subscription.\n *\n * @returns Returns a usage record when one was created successfully.\n *\n * @example\n * Creating a usage record\n * Create a usage record for the active usage billing plan\n * ```ts\n * // /app/routes/create-usage-record.ts\n * import { ActionFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const action = async ({ request }: ActionFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n *\n * const chargeBilling = await billing.createUsageRecord({\n * description: \"Usage record for product creation\",\n * price: {\n * amount: 1,\n * currencyCode: \"USD\",\n * },\n * isTest: true,\n * });\n * console.log(chargeBilling);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const USAGE_PLAN = 'Usage subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [USAGE_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Usage,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n createUsageRecord: (\n options: CreateUsageRecordOptions,\n ) => Promise;\n\n /**\n * Updates the capped amount for a usage billing plan.\n *\n * @returns Redirects to a confirmation page to update the usage billing plan.\n *\n * @example\n * Updating the capped amount for a usage billing plan.\n * Update the capped amount for the usage billing plan specified by `subscriptionLineItemId`.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { ActionFunctionArgs } from \"@remix-run/node\";\n * import { authenticate } from \"../shopify.server\";\n *\n * export const action = async ({ request }: ActionFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n *\n * await billing.updateUsageCappedAmount({\n * subscriptionLineItemId: \"gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1\",\n * cappedAmount: {\n * amount: 10,\n * currencyCode: \"USD\"\n * },\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const USAGE_PLAN = 'Usage subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [USAGE_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Usage,\n * terms: \"Usage based\"\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n updateUsageCappedAmount: (\n options: UpdateUsageCappedAmountOptions,\n ) => Promise;\n}" }, "CancelBillingOptions": { "filePath": "src/server/authenticate/admin/billing/types.ts", @@ -1772,6 +1863,28 @@ } ], "value": "export interface RequireBillingOptions\n extends Omit {\n /**\n * The plans to check for. Must be one of the values defined in the `billing` config option.\n */\n plans: (keyof Config['billing'])[];\n /**\n * How to handle the request if the shop doesn't have an active payment for any plan.\n */\n onFailure: (error: any) => Promise;\n}" + }, + "UpdateUsageCappedAmountOptions": { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "name": "UpdateUsageCappedAmountOptions", + "description": "", + "members": [ + { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "syntaxKind": "PropertySignature", + "name": "cappedAmount", + "value": "{ amount: number; currencyCode: string; }", + "description": "The maximum charge for the usage billing plan." + }, + { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "syntaxKind": "PropertySignature", + "name": "subscriptionLineItemId", + "value": "string", + "description": "The subscription line item ID to update." + } + ], + "value": "export interface UpdateUsageCappedAmountOptions {\n /**\n * The subscription line item ID to update.\n */\n subscriptionLineItemId: string;\n /**\n * The maximum charge for the usage billing plan.\n */\n cappedAmount: {\n /**\n * The amount to update.\n */\n amount: number;\n /**\n * The currency code to update.\n */\n currencyCode: string;\n };\n}" } } } @@ -1952,6 +2065,29 @@ } } ] + }, + { + "title": "updateUsageCappedAmount", + "examples": [ + { + "description": "Update the capped amount for the usage billing plan specified by `subscriptionLineItemId`.", + "codeblock": { + "title": "Updating the capped amount for a usage billing plan", + "tabs": [ + { + "title": "/app/routes/**\\/*.ts", + "code": "import { ActionFunctionArgs } from \"@remix-run/node\";\nimport { authenticate } from \"../shopify.server\";\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { billing } = await authenticate.admin(request);\n\n await billing.updateUsageCappedAmount({\n subscriptionLineItemId: \"gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1\",\n cappedAmount: {\n amount: 10,\n currencyCode: \"USD\"\n },\n });\n\n // App logic\n};", + "language": "typescript" + }, + { + "title": "shopify.server.ts", + "code": "import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n\nexport const USAGE_PLAN = 'Usage subscription';\n\nconst shopify = shopifyApp({\n // ...etc\n billing: {\n [USAGE_PLAN]: {\n lineItems: [\n {\n amount: 5,\n currencyCode: 'USD',\n interval: BillingInterval.Usage,\n terms: \"Usage based\"\n }\n ],\n },\n }\n});\nexport default shopify;\nexport const authenticate = shopify.authenticate;", + "language": "typescript" + } + ] + } + } + ] } ] } @@ -4896,9 +5032,32 @@ ] } ] + }, + { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "syntaxKind": "PropertySignature", + "name": "updateUsageCappedAmount", + "value": "(options: UpdateUsageCappedAmountOptions) => Promise", + "description": "Updates the capped amount for a usage billing plan.", + "examples": [ + { + "title": "Updating the capped amount for a usage billing plan", + "description": "Update the capped amount for the usage billing plan specified by `subscriptionLineItemId`.", + "tabs": [ + { + "code": "import { ActionFunctionArgs } from \"@remix-run/node\";\nimport { authenticate } from \"../shopify.server\";\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { billing } = await authenticate.admin(request);\n\n await billing.updateUsageCappedAmount({\n subscriptionLineItemId: \"gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1\",\n cappedAmount: {\n amount: 10,\n currencyCode: \"USD\"\n },\n });\n\n // App logic\n};", + "title": "/app/routes/**\\/*.ts" + }, + { + "code": "import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n\nexport const USAGE_PLAN = 'Usage subscription';\n\nconst shopify = shopifyApp({\n // ...etc\n billing: {\n [USAGE_PLAN]: {\n lineItems: [\n {\n amount: 5,\n currencyCode: 'USD',\n interval: BillingInterval.Usage,\n terms: \"Usage based\"\n }\n ],\n },\n }\n});\nexport default shopify;\nexport const authenticate = shopify.authenticate;", + "title": "shopify.server.ts" + } + ] + } + ] } ], - "value": "export interface BillingContext {\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Requesting billing right away.\n * Call `billing.request` in the `onFailure` callback to immediately redirect to the Shopify page to request payment.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * isTest: true,\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Redirect to a plan selection page.\n * When the app has multiple plans, create a page in your App that allows the merchant to select a plan. If a merchant does not have the required plan you can redirect them to page in your app to select one.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs, redirect } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN, ANNUAL_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN, ANNUAL_PLAN],\n * isTest: true,\n * onFailure: () => redirect('/select-plan'),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * console.log(`Shop is on ${subscription.name} (id ${subscription.id})`);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n require: (\n options: RequireBillingOptions,\n ) => Promise;\n\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Check what billing plans a merchant is subscribed to.\n * Use billing.check if you want to determine which plans are in use. Unlike `require`, `check` does not\n * throw an error if no active billing plans are present. \n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check({\n * plans: [MONTHLY_PLAN],\n * isTest: false,\n * });\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Check for payments without filtering.\n * Use billing.check to see if any payments exist for the store, regardless of whether it's a test or\n * matches one or more plans.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check();\n * // This will be true if any payment is found\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n */\n check: >(\n options?: Options,\n ) => Promise;\n\n /**\n * Requests payment for the plan.\n *\n * @returns Redirects to the confirmation URL for the payment.\n *\n * @example\n * Using a custom return URL.\n * Change where the merchant is returned to after approving the purchase using the `returnUrl` option.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * returnUrl: 'https://admin.shopify.com/store/my-store/apps/my-app/billing-page',\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Overriding plan settings.\n * Customize the plan for a merchant when requesting billing. Any fields from the plan can be overridden, as long as the billing interval for line items matches the config.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * trialDays: 14,\n * lineItems: [\n * {\n * interval: BillingInterval.Every30Days,\n * discount: { value: { percentage: 0.1 } },\n * },\n * ],\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n request: (options: RequestBillingOptions) => Promise;\n\n /**\n * Cancels an ongoing subscription, given its ID.\n *\n * @returns The cancelled subscription.\n *\n * @example\n * Cancelling a subscription.\n * Use the `billing.cancel` function to cancel an active subscription with the id returned from `billing.require`.\n * ```ts\n * // /app/routes/cancel-subscription.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * const cancelledSubscription = await billing.cancel({\n * subscriptionId: subscription.id,\n * isTest: true,\n * prorate: true,\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n cancel: (options: CancelBillingOptions) => Promise;\n\n /**\n * Creates a usage record for an app subscription.\n *\n * @returns Returns a usage record when one was created successfully.\n *\n * @example\n * Creating a usage record\n * Create a usage record for the active usage billing plan\n * ```ts\n * // /app/routes/create-usage-record.ts\n * import { ActionFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const action = async ({ request }: ActionFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n *\n * const chargeBilling = await billing.createUsageRecord({\n * description: \"Usage record for product creation\",\n * price: {\n * amount: 1,\n * currencyCode: \"USD\",\n * },\n * isTest: true,\n * });\n * console.log(chargeBilling);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const USAGE_PLAN = 'Usage subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [USAGE_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Usage,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n createUsageRecord: (\n options: CreateUsageRecordOptions,\n ) => Promise;\n}" + "value": "export interface BillingContext {\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Requesting billing right away.\n * Call `billing.request` in the `onFailure` callback to immediately redirect to the Shopify page to request payment.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * isTest: true,\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Redirect to a plan selection page.\n * When the app has multiple plans, create a page in your App that allows the merchant to select a plan. If a merchant does not have the required plan you can redirect them to page in your app to select one.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs, redirect } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN, ANNUAL_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN, ANNUAL_PLAN],\n * isTest: true,\n * onFailure: () => redirect('/select-plan'),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * console.log(`Shop is on ${subscription.name} (id ${subscription.id})`);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n require: (\n options: RequireBillingOptions,\n ) => Promise;\n\n /**\n * Checks if the shop has an active payment for any plan defined in the `billing` config option.\n *\n * @returns A promise that resolves to an object containing the active purchases for the shop.\n *\n * @example\n * Check what billing plans a merchant is subscribed to.\n * Use billing.check if you want to determine which plans are in use. Unlike `require`, `check` does not\n * throw an error if no active billing plans are present. \n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check({\n * plans: [MONTHLY_PLAN],\n * isTest: false,\n * });\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Check for payments without filtering.\n * Use billing.check to see if any payments exist for the store, regardless of whether it's a test or\n * matches one or more plans.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const { hasActivePayment, appSubscriptions } = await billing.check();\n * // This will be true if any payment is found\n * console.log(hasActivePayment);\n * console.log(appSubscriptions);\n * };\n * ```\n */\n check: >(\n options?: Options,\n ) => Promise;\n\n /**\n * Requests payment for the plan.\n *\n * @returns Redirects to the confirmation URL for the payment.\n *\n * @example\n * Using a custom return URL.\n * Change where the merchant is returned to after approving the purchase using the `returnUrl` option.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * returnUrl: 'https://admin.shopify.com/store/my-store/apps/my-app/billing-page',\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n *\n * @example\n * Overriding plan settings.\n * Customize the plan for a merchant when requesting billing. Any fields from the plan can be overridden, as long as the billing interval for line items matches the config.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({\n * plan: MONTHLY_PLAN,\n * isTest: true,\n * trialDays: 14,\n * lineItems: [\n * {\n * interval: BillingInterval.Every30Days,\n * discount: { value: { percentage: 0.1 } },\n * },\n * ],\n * }),\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n request: (options: RequestBillingOptions) => Promise;\n\n /**\n * Cancels an ongoing subscription, given its ID.\n *\n * @returns The cancelled subscription.\n *\n * @example\n * Cancelling a subscription.\n * Use the `billing.cancel` function to cancel an active subscription with the id returned from `billing.require`.\n * ```ts\n * // /app/routes/cancel-subscription.ts\n * import { LoaderFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const loader = async ({ request }: LoaderFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n * const billingCheck = await billing.require({\n * plans: [MONTHLY_PLAN],\n * onFailure: async () => billing.request({ plan: MONTHLY_PLAN }),\n * });\n *\n * const subscription = billingCheck.appSubscriptions[0];\n * const cancelledSubscription = await billing.cancel({\n * subscriptionId: subscription.id,\n * isTest: true,\n * prorate: true,\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const MONTHLY_PLAN = 'Monthly subscription';\n * export const ANNUAL_PLAN = 'Annual subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [MONTHLY_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Every30Days,\n * }\n * ],\n * },\n * [ANNUAL_PLAN]: {\n * lineItems: [\n * {\n * amount: 50,\n * currencyCode: 'USD',\n * interval: BillingInterval.Annual,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n cancel: (options: CancelBillingOptions) => Promise;\n\n /**\n * Creates a usage record for an app subscription.\n *\n * @returns Returns a usage record when one was created successfully.\n *\n * @example\n * Creating a usage record\n * Create a usage record for the active usage billing plan\n * ```ts\n * // /app/routes/create-usage-record.ts\n * import { ActionFunctionArgs } from \"@remix-run/node\";\n * import { authenticate, MONTHLY_PLAN } from \"../shopify.server\";\n *\n * export const action = async ({ request }: ActionFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n *\n * const chargeBilling = await billing.createUsageRecord({\n * description: \"Usage record for product creation\",\n * price: {\n * amount: 1,\n * currencyCode: \"USD\",\n * },\n * isTest: true,\n * });\n * console.log(chargeBilling);\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const USAGE_PLAN = 'Usage subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [USAGE_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Usage,\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n createUsageRecord: (\n options: CreateUsageRecordOptions,\n ) => Promise;\n\n /**\n * Updates the capped amount for a usage billing plan.\n *\n * @returns Redirects to a confirmation page to update the usage billing plan.\n *\n * @example\n * Updating the capped amount for a usage billing plan.\n * Update the capped amount for the usage billing plan specified by `subscriptionLineItemId`.\n * ```ts\n * // /app/routes/**\\/*.ts\n * import { ActionFunctionArgs } from \"@remix-run/node\";\n * import { authenticate } from \"../shopify.server\";\n *\n * export const action = async ({ request }: ActionFunctionArgs) => {\n * const { billing } = await authenticate.admin(request);\n *\n * await billing.updateUsageCappedAmount({\n * subscriptionLineItemId: \"gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1\",\n * cappedAmount: {\n * amount: 10,\n * currencyCode: \"USD\"\n * },\n * });\n *\n * // App logic\n * };\n * ```\n * ```ts\n * // shopify.server.ts\n * import { shopifyApp, BillingInterval } from \"@shopify/shopify-app-remix/server\";\n *\n * export const USAGE_PLAN = 'Usage subscription';\n *\n * const shopify = shopifyApp({\n * // ...etc\n * billing: {\n * [USAGE_PLAN]: {\n * lineItems: [\n * {\n * amount: 5,\n * currencyCode: 'USD',\n * interval: BillingInterval.Usage,\n * terms: \"Usage based\"\n * }\n * ],\n * },\n * }\n * });\n * export default shopify;\n * export const authenticate = shopify.authenticate;\n * ```\n */\n updateUsageCappedAmount: (\n options: UpdateUsageCappedAmountOptions,\n ) => Promise;\n}" }, "CancelBillingOptions": { "filePath": "src/server/authenticate/admin/billing/types.ts", @@ -5061,6 +5220,28 @@ ], "value": "export interface RequireBillingOptions\n extends Omit {\n /**\n * The plans to check for. Must be one of the values defined in the `billing` config option.\n */\n plans: (keyof Config['billing'])[];\n /**\n * How to handle the request if the shop doesn't have an active payment for any plan.\n */\n onFailure: (error: any) => Promise;\n}" }, + "UpdateUsageCappedAmountOptions": { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "name": "UpdateUsageCappedAmountOptions", + "description": "", + "members": [ + { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "syntaxKind": "PropertySignature", + "name": "cappedAmount", + "value": "{ amount: number; currencyCode: string; }", + "description": "The maximum charge for the usage billing plan." + }, + { + "filePath": "src/server/authenticate/admin/billing/types.ts", + "syntaxKind": "PropertySignature", + "name": "subscriptionLineItemId", + "value": "string", + "description": "The subscription line item ID to update." + } + ], + "value": "export interface UpdateUsageCappedAmountOptions {\n /**\n * The subscription line item ID to update.\n */\n subscriptionLineItemId: string;\n /**\n * The maximum charge for the usage billing plan.\n */\n cappedAmount: {\n /**\n * The amount to update.\n */\n amount: number;\n /**\n * The currency code to update.\n */\n currencyCode: string;\n };\n}" + }, "EnsureCORSFunction": { "filePath": "src/server/authenticate/helpers/ensure-cors-headers.ts", "name": "EnsureCORSFunction", @@ -7772,7 +7953,7 @@ "filePath": "../shopify-api/dist/ts/lib/types.d.ts", "syntaxKind": "EnumDeclaration", "name": "ApiVersion", - "value": "export declare enum ApiVersion {\n October22 = \"2022-10\",\n January23 = \"2023-01\",\n April23 = \"2023-04\",\n July23 = \"2023-07\",\n October23 = \"2023-10\",\n January24 = \"2024-01\",\n April24 = \"2024-04\",\n July24 = \"2024-07\",\n Unstable = \"unstable\"\n}", + "value": "export declare enum ApiVersion {\n October22 = \"2022-10\",\n January23 = \"2023-01\",\n April23 = \"2023-04\",\n July23 = \"2023-07\",\n October23 = \"2023-10\",\n January24 = \"2024-01\",\n April24 = \"2024-04\",\n July24 = \"2024-07\",\n October24 = \"2024-10\",\n Unstable = \"unstable\"\n}", "members": [ { "filePath": "../shopify-api/dist/ts/lib/types.d.ts", @@ -7814,6 +7995,11 @@ "name": "July24", "value": "2024-07" }, + { + "filePath": "../shopify-api/dist/ts/lib/types.d.ts", + "name": "October24", + "value": "2024-10" + }, { "filePath": "../shopify-api/dist/ts/lib/types.d.ts", "name": "Unstable", diff --git a/packages/apps/shopify-app-remix/docs/generated/generated_static_pages.json b/packages/apps/shopify-app-remix/docs/generated/generated_static_pages.json index 4a9724cf43..4506b49edb 100644 --- a/packages/apps/shopify-app-remix/docs/generated/generated_static_pages.json +++ b/packages/apps/shopify-app-remix/docs/generated/generated_static_pages.json @@ -568,15 +568,15 @@ { "id": "guide-webhooks", "title": "Subscribing to webhooks", - "description": "Your app must respond to [mandatory webhook topics](/docs/apps/webhooks/configuration/mandatory-webhooks). In addition, your app can register [optional webhook topics](/docs/api/admin-rest/current/resources/webhook#event-topics).", + "description": "Your app must respond to [mandatory webhook topics](/docs/apps/webhooks/configuration/mandatory-webhooks). In addition, your app can register [optional webhook topics](/docs/api/admin-rest/current/resources/webhook#event-topics).\n\nThere are app-specific and shop-specific webhooks. We recommend app-specific webhooks for most apps, but there are reasons to register shop-specific webhooks. For more information, please read [App-specific vs shop-specific webhooks](https://shopify.dev/docs/apps/build/webhooks/subscribe#app-specific-vs-shop-specific-subscriptions).", "sections": [ { "type": "Generic", "anchorLink": "config", - "title": "Subscribe using the app configuration file", - "sectionContent": "The easiest and recommended way to configure your webhooks is to edit your app configuration file. You can find more info in the [webhooks documentation](/docs/apps/webhooks/getting-started-declarative).\n\nTo set up a simple HTTPS webhook subscription, you can follow these steps:\n1. Add in `shopify.app.toml` the topic you want to subscribe to. In this example we subscribe to the `APP_UNINSTALLED` topic.\n1. Review the required scopes for the webhook topics, and update your [app scopes](/docs/apps/tools/cli/configuration#access_scopes) as necessary.\n1. Run `shopify app deploy` from the CLI to save your webhooks configuration.", + "title": "App-specific webhooks (recommended)", + "sectionContent": "The easiest way to configure webhooks is to use app-specific webhooks in `shopify.app.toml`. You can find more info in the [webhooks documentation](/docs/apps/webhooks/getting-started-declarative).\n\nTo set up a simple HTTPS webhook subscription, you can follow these steps:\n1. Add the topic to subscribe to in `shopify.app.toml`. In this example we subscribe to the `APP_UNINSTALLED` topic.\n1. Review the required scopes for the webhook topics, and update your [app scopes](/docs/apps/tools/cli/configuration#access_scopes) as necessary.\n1. Run `shopify app deploy` from the CLI to save your webhooks configuration.", "codeblock": { - "title": "Configure webhooks subscriptions with the app configuration", + "title": "Configure app-specific webhooks", "tabs": [ { "title": "shopify.app.toml", @@ -589,14 +589,14 @@ { "type": "Generic", "anchorLink": "config", - "title": "Subscribe using the API", - "sectionContent": "Configure `shopifyApp` and setup webhook subscription with the following steps:\n1. The webhooks you want to subscribe to. In this example we subscribe to the `APP_UNINSTALLED` topic.\n1. The code to register the `APP_UNINSTALLED` topic after a merchant installs you app. Here `shopifyApp` provides an `afterAuth` hook.\n1. Review the required scopes for the webhook topics, and update your [app scopes](/docs/apps/tools/cli/configuration#access_scopes) as necessary.\n\n> Note: You can't register mandatory topics using this package, you must [configure those in the Partner Dashboard](/docs/apps/webhooks/configuration/mandatory-webhooks) instead.", + "title": "Shop-specific webhooks", + "sectionContent": "Shop-specific webhooks are useful when you need to subscribe to different webhook topics for different shops, or when a topic is not supported by app-specific webhooks.Configure `shopifyApp` and to setup shop-specific webhook subscriptions with the following steps:\n1. The webhooks you want to subscribe to. In this example we subscribe to the `APP_UNINSTALLED` topic.\n1. The code to register the `APP_UNINSTALLED` topic after a merchant installs you app. Here `shopifyApp` provides an `afterAuth` hook.\n1. Review the required scopes for the webhook topics, and update your [app scopes](/docs/apps/tools/cli/configuration#access_scopes) as necessary.\n\n> Note: You can't register mandatory topics using this package, you must [configure those in the Partner Dashboard](/docs/apps/webhooks/configuration/mandatory-webhooks) instead.", "codeblock": { - "title": "Configure webhooks subscriptions with the API", + "title": "Configure shop-specific webhooks", "tabs": [ { "title": "/app/shopify.server.ts", - "code": "import {shopifyApp, DeliveryMethod} from '@shopify/shopify-app-remix/server';\n\nconst shopify = shopifyApp({\n apiKey: 'abcde1234567890',\n // ...etc\n webhooks: {\n APP_UNINSTALLED: {\n deliveryMethod: DeliveryMethod.Http,\n callbackUrl: '/webhooks',\n },\n },\n hooks: {\n afterAuth: async ({session}) => {\n shopify.registerWebhooks({session});\n },\n },\n});\n\nexport const authenticate = shopify.authenticate;\n", + "code": "import {shopifyApp, DeliveryMethod} from '@shopify/shopify-app-remix/server';\n\nconst shopify = shopifyApp({\n apiKey: 'abcde1234567890',\n // ...etc\n webhooks: {\n APP_UNINSTALLED: {\n deliveryMethod: DeliveryMethod.Http,\n callbackUrl: '/webhooks',\n },\n },\n hooks: {\n afterAuth: async ({session}) => {\n // Register webhooks for the shop\n // In this example, every shop will have these webhooks\n // You could wrap this in some custom shop specific conditional logic if needed\n shopify.registerWebhooks({session});\n },\n },\n});\n\nexport const authenticate = shopify.authenticate;\n", "language": "tsx" } ] @@ -612,7 +612,7 @@ "tabs": [ { "title": "/app/routes/webhooks.tsx", - "code": "import {ActionFunctionArgs} from '@remix-run/node';\n\nimport db from '../db.server';\n\nimport {authenticate} from '~/shopify.server';\n\nexport const action = async ({request}: ActionFunctionArgs) => {\n const {topic, shop, session} = await authenticate.webhook(request);\n\n switch (topic) {\n case 'APP_UNINSTALLED':\n if (session) {\n await db.session.deleteMany({where: {shop}});\n }\n break;\n case 'CUSTOMERS_DATA_REQUEST':\n case 'CUSTOMERS_REDACT':\n case 'SHOP_REDACT':\n default:\n throw new Response('Unhandled webhook topic', {status: 404});\n }\n\n throw new Response();\n};\n", + "code": "import {ActionFunctionArgs} from '@remix-run/node';\n\nimport db from '../db.server';\n\nimport {authenticate} from '~/shopify.server';\n\nexport const action = async ({request}: ActionFunctionArgs) => {\n const {topic, shop, session} = await authenticate.webhook(request);\n\n switch (topic) {\n case 'APP_UNINSTALLED':\n // Webhook requests can trigger after an app is uninstalled\n // If the app is already uninstalled, the session may be undefined.\n if (session) {\n await db.session.deleteMany({where: {shop}});\n }\n break;\n case 'CUSTOMERS_DATA_REQUEST':\n case 'CUSTOMERS_REDACT':\n case 'SHOP_REDACT':\n default:\n throw new Response('Unhandled webhook topic', {status: 404});\n }\n\n throw new Response();\n};\n", "language": "tsx" } ] diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/authenticate.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/authenticate.ts index e41c57d99b..aa75601ee4 100644 --- a/packages/apps/shopify-app-remix/src/server/authenticate/admin/authenticate.ts +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/authenticate.ts @@ -17,6 +17,7 @@ import { requireBillingFactory, checkBillingFactory, createUsageRecordFactory, + updateUsageCappedAmountFactory, } from './billing'; import type { AdminContext, @@ -97,6 +98,11 @@ export function authStrategyFactory< request: requestBillingFactory(params, request, session), cancel: cancelBillingFactory(params, request, session), createUsageRecord: createUsageRecordFactory(params, request, session), + updateUsageCappedAmount: updateUsageCappedAmountFactory( + params, + request, + session, + ), }, session, diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/__tests__/mock-responses.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/__tests__/mock-responses.ts index 7d3aa5aeb8..c4557f5e84 100644 --- a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/__tests__/mock-responses.ts +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/__tests__/mock-responses.ts @@ -170,3 +170,36 @@ export const USAGE_RECORD = { }, }, }; + +export const UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID = 'gid://123'; +export const APP_SUBSCRIPTION_LINE_ITEM_UPDATE_PAYLOAD = { + userErrors: [], + confirmationUrl: CONFIRMATION_URL, + appSubscription: { + id: UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID, + name: PLAN_1, + test: true, + status: 'ACTIVE', + }, +}; + +export const UPDATE_CAPPED_AMOUNT_CONFIRMATION_RESPONSE = { + ...APP_SUBSCRIPTION_LINE_ITEM_UPDATE_PAYLOAD, + userErrors: undefined, +}; + +export const USAGE_SUBSRIPTION_CAPPED_AMOUNT_UPDATE_RESPONSE = JSON.stringify({ + data: { + appSubscriptionLineItemUpdate: APP_SUBSCRIPTION_LINE_ITEM_UPDATE_PAYLOAD, + }, +}); + +export const USAGE_SUBSRIPTION_CAPPED_AMOUNT_UPDATE_RESPONSE_WITH_USER_ERRORS = + JSON.stringify({ + data: { + appSubscriptionLineItemUpdate: { + ...APP_SUBSCRIPTION_LINE_ITEM_UPDATE_PAYLOAD, + userErrors: ['Oops, something went wrong'], + }, + }, + }); diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/__tests__/update-usage-subscription-capped-amount.test.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/__tests__/update-usage-subscription-capped-amount.test.ts new file mode 100644 index 0000000000..ae0604bc5f --- /dev/null +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/__tests__/update-usage-subscription-capped-amount.test.ts @@ -0,0 +1,342 @@ +import { + BillingConfigSubscriptionLineItemPlan, + BillingError, + BillingInterval, + HttpResponseError, + SESSION_COOKIE_NAME, +} from '@shopify/shopify-api'; + +import {shopifyApp} from '../../../..'; +import { + APP_URL, + BASE64_HOST, + GRAPHQL_URL, + TEST_SHOP, + expectBeginAuthRedirect, + expectExitIframeRedirect, + getJwt, + getThrownResponse, + setUpValidSession, + signRequestCookie, + testConfig, + mockExternalRequest, + mockExternalRequests, +} from '../../../../__test-helpers'; +import {REAUTH_URL_HEADER} from '../../../const'; + +import * as responses from './mock-responses'; + +const BILLING_CONFIG = { + [responses.PLAN_1]: { + lineItems: [ + { + amount: 5, + currencyCode: 'USD', + interval: BillingInterval.Usage, + terms: 'Usage based', + }, + ], + } as BillingConfigSubscriptionLineItemPlan, +}; + +describe('Update usage billing plan capped amount', () => { + it('redirects to confirmation URL when successful and at the top level for non-embedded apps', async () => { + // GIVEN + const shopify = shopifyApp( + testConfig({isEmbeddedApp: false, billing: BILLING_CONFIG}), + ); + const session = await setUpValidSession(shopify.sessionStorage); + + await mockExternalRequests({ + request: new Request(GRAPHQL_URL, {method: 'POST', body: 'test'}), + response: new Response( + responses.USAGE_SUBSRIPTION_CAPPED_AMOUNT_UPDATE_RESPONSE, + ), + }); + + const request = new Request(`${APP_URL}/billing?shop=${TEST_SHOP}`); + signRequestCookie({ + request, + cookieName: SESSION_COOKIE_NAME, + cookieValue: session.id, + }); + + const {billing} = await shopify.authenticate.admin(request); + + // WHEN + const response = await getThrownResponse( + async () => + billing.updateUsageCappedAmount({ + subscriptionLineItemId: + responses.UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID, + cappedAmount: {amount: 10, currencyCode: 'USD'}, + }), + request, + ); + + // THEN + expect(response.status).toEqual(302); + expect(response.headers.get('Location')).toEqual( + responses.CONFIRMATION_URL, + ); + }); + + it('redirects to exit-iframe with payment confirmation URL when successful using app bridge when embedded', async () => { + // GIVEN + const shopify = shopifyApp(testConfig({billing: BILLING_CONFIG})); + await setUpValidSession(shopify.sessionStorage); + + await mockExternalRequest({ + request: new Request(GRAPHQL_URL, {method: 'POST', body: 'test'}), + response: new Response( + responses.USAGE_SUBSRIPTION_CAPPED_AMOUNT_UPDATE_RESPONSE, + ), + }); + + const {token} = getJwt(); + const request = new Request( + `${APP_URL}/billing?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ); + + const {billing} = await shopify.authenticate.admin(request); + + // WHEN + const response = await getThrownResponse( + async () => + billing.updateUsageCappedAmount({ + subscriptionLineItemId: + responses.UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID, + cappedAmount: {amount: 10, currencyCode: 'USD'}, + }), + request, + ); + + // THEN + expectExitIframeRedirect(response, { + destination: responses.CONFIRMATION_URL, + addHostToExitIframePath: false, + }); + }); + + it('returns redirection headers when successful during fetch requests', async () => { + // GIVEN + const shopify = shopifyApp(testConfig({billing: BILLING_CONFIG})); + await setUpValidSession(shopify.sessionStorage); + + await mockExternalRequest({ + request: new Request(GRAPHQL_URL, {method: 'POST', body: 'test'}), + response: new Response( + responses.USAGE_SUBSRIPTION_CAPPED_AMOUNT_UPDATE_RESPONSE, + ), + }); + + const request = new Request(`${APP_URL}/billing`, { + headers: { + Authorization: `Bearer ${getJwt().token}`, + }, + }); + + const {billing} = await shopify.authenticate.admin(request); + + // WHEN + const response = await getThrownResponse( + async () => + billing.updateUsageCappedAmount({ + subscriptionLineItemId: + responses.UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID, + cappedAmount: {amount: 10, currencyCode: 'USD'}, + }), + request, + ); + + // THEN + expect(response.status).toEqual(401); + expect(response.headers.get(REAUTH_URL_HEADER)).toEqual( + responses.CONFIRMATION_URL, + ); + }); + + it('redirects to authentication when at the top level when Shopify invalidated the session', async () => { + // GIVEN + const config = testConfig(); + const shopify = shopifyApp( + testConfig({isEmbeddedApp: false, billing: BILLING_CONFIG}), + ); + const session = await setUpValidSession(shopify.sessionStorage); + + await mockExternalRequests({ + request: new Request(GRAPHQL_URL, {method: 'POST', body: 'test'}), + response: new Response(undefined, { + status: 401, + statusText: 'Unauthorized', + }), + }); + + const request = new Request(`${APP_URL}/billing?shop=${TEST_SHOP}`); + signRequestCookie({ + request, + cookieName: SESSION_COOKIE_NAME, + cookieValue: session.id, + }); + + const {billing} = await shopify.authenticate.admin(request); + + // WHEN + const response = await getThrownResponse( + async () => + billing.updateUsageCappedAmount({ + subscriptionLineItemId: + responses.UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID, + cappedAmount: {amount: 10, currencyCode: 'USD'}, + }), + request, + ); + + // THEN + expectBeginAuthRedirect(config, response); + }); + + it('redirects to exit-iframe with authentication using app bridge when embedded and Shopify invalidated the session', async () => { + // GIVEN + const config = testConfig(); + const shopify = shopifyApp({...config, billing: BILLING_CONFIG}); + await setUpValidSession(shopify.sessionStorage); + + await mockExternalRequest({ + request: new Request(GRAPHQL_URL, {method: 'POST', body: 'test'}), + response: new Response(undefined, { + status: 401, + statusText: 'Unauthorized', + }), + }); + + const {token} = getJwt(); + const request = new Request( + `${APP_URL}/billing?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ); + + const {billing} = await shopify.authenticate.admin(request); + + // WHEN + const response = await getThrownResponse( + async () => + billing.updateUsageCappedAmount({ + subscriptionLineItemId: + responses.UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID, + cappedAmount: {amount: 10, currencyCode: 'USD'}, + }), + request, + ); + + // THEN + const shopSession = await config.sessionStorage.loadSession( + `offline_${TEST_SHOP}`, + ); + expect(shopSession).toBeDefined(); + expect(shopSession!.accessToken).toBeUndefined(); + expectExitIframeRedirect(response); + }); + + it('returns redirection headers during fetch requests when Shopify invalidated the session', async () => { + // GIVEN + const shopify = shopifyApp(testConfig({billing: BILLING_CONFIG})); + await setUpValidSession(shopify.sessionStorage); + + await mockExternalRequest({ + request: new Request(GRAPHQL_URL, {method: 'POST', body: 'test'}), + response: new Response(undefined, { + status: 401, + statusText: 'Unauthorized', + }), + }); + + const request = new Request(`${APP_URL}/billing`, { + headers: { + Authorization: `Bearer ${getJwt().token}`, + }, + }); + + const {billing} = await shopify.authenticate.admin(request); + + // WHEN + const response = await getThrownResponse( + async () => + billing.updateUsageCappedAmount({ + subscriptionLineItemId: + responses.UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID, + cappedAmount: {amount: 10, currencyCode: 'USD'}, + }), + request, + ); + + // THEN + expect(response.status).toEqual(401); + + const reauthUrl = new URL(response.headers.get(REAUTH_URL_HEADER)!); + expect(reauthUrl.origin).toEqual(APP_URL); + expect(reauthUrl.pathname).toEqual('/auth'); + }); + + it('throws errors other than authentication errors', async () => { + // GIVEN + const shopify = shopifyApp( + testConfig({isEmbeddedApp: false, billing: BILLING_CONFIG}), + ); + const session = await setUpValidSession(shopify.sessionStorage); + + await mockExternalRequests({ + request: new Request(GRAPHQL_URL, {method: 'POST', body: 'test'}), + response: new Response(undefined, { + status: 500, + statusText: 'Internal Server Error', + }), + }); + + const request = new Request(`${APP_URL}/billing?shop=${TEST_SHOP}`); + signRequestCookie({ + request, + cookieName: SESSION_COOKIE_NAME, + cookieValue: session.id, + }); + + const {billing} = await shopify.authenticate.admin(request); + + // THEN + await expect( + billing.updateUsageCappedAmount({ + subscriptionLineItemId: + responses.UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID, + cappedAmount: {amount: 10, currencyCode: 'USD'}, + }), + ).rejects.toThrow(HttpResponseError); + }); + + it('throws a BillingError when the response contains user errors', async () => { + // GIVEN + const shopify = shopifyApp(testConfig({billing: BILLING_CONFIG})); + await setUpValidSession(shopify.sessionStorage); + + await mockExternalRequest({ + request: new Request(GRAPHQL_URL, {method: 'POST', body: 'test'}), + response: new Response( + responses.USAGE_SUBSRIPTION_CAPPED_AMOUNT_UPDATE_RESPONSE_WITH_USER_ERRORS, + ), + }); + + const {token} = getJwt(); + const {billing} = await shopify.authenticate.admin( + new Request( + `${APP_URL}/billing?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`, + ), + ); + + // THEN + await expect( + billing.updateUsageCappedAmount({ + subscriptionLineItemId: + responses.UPDATE_USAGE_CAPPED_AMOUNT_SUBSCRIPTION_ID, + cappedAmount: {amount: 10, currencyCode: 'USD'}, + }), + ).rejects.toThrow(BillingError); + }); +}); diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/helpers.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/helpers.ts new file mode 100644 index 0000000000..ca137c530a --- /dev/null +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/helpers.ts @@ -0,0 +1,42 @@ +import {redirect} from '@remix-run/server-runtime'; + +import {BasicParams} from '../../../types'; +import {getAppBridgeHeaders} from '../helpers'; + +export function redirectOutOfApp( + params: BasicParams, + request: Request, + url: string, + shop: string, +): never { + const {config, logger} = params; + + logger.debug('Redirecting out of app', {url}); + + const requestUrl = new URL(request.url); + const isEmbeddedRequest = requestUrl.searchParams.get('embedded') === '1'; + const isXhrRequest = request.headers.get('authorization'); + + if (isXhrRequest) { + // eslint-disable-next-line no-warning-comments + // TODO Check this with the beta flag disabled (with the bounce page) + // Remix is not including the X-Shopify-API-Request-Failure-Reauthorize-Url when throwing a Response + // https://github.com/remix-run/remix/issues/5356 + throw new Response(undefined, { + status: 401, + statusText: 'Unauthorized', + headers: getAppBridgeHeaders(url), + }); + } else if (isEmbeddedRequest) { + const params = new URLSearchParams({ + shop, + host: requestUrl.searchParams.get('host')!, + exitIframe: url, + }); + + throw redirect(`${config.auth.exitIframePath}?${params.toString()}`); + } else { + // This will only ever happen for non-embedded apps, because the authenticator will stop before reaching this point + throw redirect(url); + } +} diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/index.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/index.ts index 3bb69b2364..1db39d3a2d 100644 --- a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/index.ts +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/index.ts @@ -3,3 +3,4 @@ export {requireBillingFactory} from './require'; export {requestBillingFactory} from './request'; export {checkBillingFactory} from './check'; export {createUsageRecordFactory} from './create-usage-record'; +export {updateUsageCappedAmountFactory} from './update-usage-subscription-capped-amount'; diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/request.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/request.ts index 68c9153b83..be66f873ee 100644 --- a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/request.ts +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/request.ts @@ -3,13 +3,13 @@ import { HttpResponseError, Session, } from '@shopify/shopify-api'; -import {redirect} from '@remix-run/server-runtime'; import {AppConfigArg} from '../../../config-types'; import {BasicParams} from '../../../types'; -import {getAppBridgeHeaders, redirectToAuthPage} from '../helpers'; +import {redirectToAuthPage} from '../helpers'; import {invalidateAccessToken} from '../../helpers'; +import {redirectOutOfApp} from './helpers'; import type {RequestBillingOptions} from './types'; export function requestBillingFactory( @@ -62,41 +62,3 @@ export function requestBillingFactory( ); }; } - -function redirectOutOfApp( - params: BasicParams, - request: Request, - url: string, - shop: string, -): never { - const {config, logger} = params; - - logger.debug('Redirecting out of app', {url}); - - const requestUrl = new URL(request.url); - const isEmbeddedRequest = requestUrl.searchParams.get('embedded') === '1'; - const isXhrRequest = request.headers.get('authorization'); - - if (isXhrRequest) { - // eslint-disable-next-line no-warning-comments - // TODO Check this with the beta flag disabled (with the bounce page) - // Remix is not including the X-Shopify-API-Request-Failure-Reauthorize-Url when throwing a Response - // https://github.com/remix-run/remix/issues/5356 - throw new Response(undefined, { - status: 401, - statusText: 'Unauthorized', - headers: getAppBridgeHeaders(url), - }); - } else if (isEmbeddedRequest) { - const params = new URLSearchParams({ - shop, - host: requestUrl.searchParams.get('host')!, - exitIframe: url, - }); - - throw redirect(`${config.auth.exitIframePath}?${params.toString()}`); - } else { - // This will only ever happen for non-embedded apps, because the authenticator will stop before reaching this point - throw redirect(url); - } -} diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/types.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/types.ts index c99592646d..c42b09ba51 100644 --- a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/types.ts +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/types.ts @@ -98,6 +98,26 @@ export interface CreateUsageRecordOptions { idempotencyKey?: string; } +export interface UpdateUsageCappedAmountOptions { + /** + * The subscription line item ID to update. + */ + subscriptionLineItemId: string; + /** + * The maximum charge for the usage billing plan. + */ + cappedAmount: { + /** + * The amount to update. + */ + amount: number; + /** + * The currency code to update. + */ + currencyCode: string; + }; +} + export interface BillingContext { /** * Checks if the shop has an active payment for any plan defined in the `billing` config option. @@ -534,4 +554,60 @@ export interface BillingContext { createUsageRecord: ( options: CreateUsageRecordOptions, ) => Promise; + + /** + * Updates the capped amount for a usage billing plan. + * + * @returns Redirects to a confirmation page to update the usage billing plan. + * + * @example + * Updating the capped amount for a usage billing plan. + * Update the capped amount for the usage billing plan specified by `subscriptionLineItemId`. + * ```ts + * // /app/routes/**\/*.ts + * import { ActionFunctionArgs } from "@remix-run/node"; + * import { authenticate } from "../shopify.server"; + * + * export const action = async ({ request }: ActionFunctionArgs) => { + * const { billing } = await authenticate.admin(request); + * + * await billing.updateUsageCappedAmount({ + * subscriptionLineItemId: "gid://shopify/AppSubscriptionLineItem/12345?v=1&index=1", + * cappedAmount: { + * amount: 10, + * currencyCode: "USD" + * }, + * }); + * + * // App logic + * }; + * ``` + * ```ts + * // shopify.server.ts + * import { shopifyApp, BillingInterval } from "@shopify/shopify-app-remix/server"; + * + * export const USAGE_PLAN = 'Usage subscription'; + * + * const shopify = shopifyApp({ + * // ...etc + * billing: { + * [USAGE_PLAN]: { + * lineItems: [ + * { + * amount: 5, + * currencyCode: 'USD', + * interval: BillingInterval.Usage, + * terms: "Usage based" + * } + * ], + * }, + * } + * }); + * export default shopify; + * export const authenticate = shopify.authenticate; + * ``` + */ + updateUsageCappedAmount: ( + options: UpdateUsageCappedAmountOptions, + ) => Promise; } diff --git a/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/update-usage-subscription-capped-amount.ts b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/update-usage-subscription-capped-amount.ts new file mode 100644 index 0000000000..b9a81f302d --- /dev/null +++ b/packages/apps/shopify-app-remix/src/server/authenticate/admin/billing/update-usage-subscription-capped-amount.ts @@ -0,0 +1,55 @@ +import { + HttpResponseError, + Session, + UpdateCappedAmountConfirmation, +} from '@shopify/shopify-api'; + +import type {BasicParams} from '../../../types'; +import {redirectToAuthPage} from '../helpers'; +import {invalidateAccessToken} from '../../helpers'; + +import {UpdateUsageCappedAmountOptions} from './types'; +import {redirectOutOfApp} from './helpers'; + +export function updateUsageCappedAmountFactory( + params: BasicParams, + request: Request, + session: Session, +) { + return async function updateUsageCappedAmount( + options: UpdateUsageCappedAmountOptions, + ): Promise { + const {api, logger} = params; + + logger.debug('Updating usage subscription capped amount', { + shop: session.shop, + ...options, + }); + + let result: UpdateCappedAmountConfirmation; + try { + result = await api.billing.updateUsageCappedAmount({ + session, + subscriptionLineItemId: options.subscriptionLineItemId, + cappedAmount: options.cappedAmount, + }); + } catch (error) { + if (error instanceof HttpResponseError && error.response.code === 401) { + logger.debug('API token was invalid, redirecting to OAuth', { + shop: session.shop, + }); + await invalidateAccessToken(params, session); + throw await redirectToAuthPage(params, request, session.shop); + } else { + throw error; + } + } + + throw redirectOutOfApp( + params, + request, + result.confirmationUrl, + session.shop, + ); + }; +}