From fa37ae57ab6021a1629d84c3f7e1d4e79a1af879 Mon Sep 17 00:00:00 2001 From: Paulo Margarido <64600052+paulomarg@users.noreply.github.com> Date: Tue, 9 Aug 2022 10:24:34 -0400 Subject: [PATCH] Add billing support --- CHANGELOG.md | 1 + README.md | 1 + docs/README.md | 1 + docs/usage/billing.md | 74 +++++ src/base-types.ts | 2 + src/billing/__tests__/check.test.ts | 259 ++++++++++++++++++ src/billing/__tests__/check_responses.ts | 136 +++++++++ src/billing/check.ts | 36 +++ src/billing/has_active_payment.ts | 123 +++++++++ src/billing/index.ts | 8 + src/billing/is_recurring.ts | 20 ++ src/billing/request_payment.ts | 178 ++++++++++++ src/billing/types.ts | 65 +++++ src/context.ts | 4 + src/error.ts | 11 + src/index.ts | 2 + src/types.ts | 1 + .../__tests__/get-embedded-app-url.test.ts | 2 +- src/utils/get-embedded-app-url.ts | 8 +- src/utils/setup-jest.ts | 1 + 20 files changed, 931 insertions(+), 2 deletions(-) create mode 100644 docs/usage/billing.md create mode 100644 src/billing/__tests__/check.test.ts create mode 100644 src/billing/__tests__/check_responses.ts create mode 100644 src/billing/check.ts create mode 100644 src/billing/has_active_payment.ts create mode 100644 src/billing/index.ts create mode 100644 src/billing/is_recurring.ts create mode 100644 src/billing/request_payment.ts create mode 100644 src/billing/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b21dd61..1d331f0c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## Unreleased +- Add support for billing to the library [#449](https://github.com/Shopify/shopify-api-node/pull/449) - Allow dynamically typing the body of REST and GraphQL request responses, so callers don't need to cast it [#447](https://github.com/Shopify/shopify-api-node/pull/447) ## [5.0.1] - 2022-08-03 diff --git a/README.md b/README.md index c6f63a792..9bb11b2db 100644 --- a/README.md +++ b/README.md @@ -51,5 +51,6 @@ You can follow our [getting started guide](docs/), which will provide instructio - [Webhooks](docs/usage/webhooks.md) - [Register a Webhook](docs/usage/webhooks.md#register-a-webhook) - [Process a Webhook](docs/usage/webhooks.md#process-a-webhook) +- [Billing](docs/usage/billing.md) - [Known issues and caveats](docs/issues.md) - [Notes on session handling](docs/issues.md#notes-on-session-handling) diff --git a/docs/README.md b/docs/README.md index 0ac7b9bef..9743f47d6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ You can follow our getting started guide, which will provide instructions on how - [Webhooks](usage/webhooks.md) - [Register a Webhook](usage/webhooks.md#register-a-webhook) - [Process a Webhook](usage/webhooks.md#process-a-webhook) +- [Billing](docs/usage/billing.md) - [Create a `CustomSessionStorage` Solution](usage/customsessions.md) - [Usage Example with Redis](usage/customsessions.md#example-usage) - [Known issues and caveats](issues.md) diff --git a/docs/usage/billing.md b/docs/usage/billing.md new file mode 100644 index 000000000..fba14cdb4 --- /dev/null +++ b/docs/usage/billing.md @@ -0,0 +1,74 @@ +# Billing for your app + +Some partners might wish to charge merchants for using their app. +Shopify provides API endpoints that enable apps to trigger purchases in the Shopify platform, so that apps don't need to implement their own billing features. + +This library provides support for billing apps by: + +1. Checking if the store has already paid for the app +1. Triggering a payment if not + +Learn more about billing in [our documentation](https://shopify.dev/apps/billing). + +## Setting up for billing + +To trigger the billing behaviour, you should set the `BILLING` value when calling `Shopify.Context.initialize`. +This setting is an object containing the following values: + +| Parameter | Type | Required? | Default Value | Notes | +| -------------- | ----------------- | :-------: | :-----------: | ------------------------------------------------------------------ | +| `chargeName` | `string` | Yes | - | The charge name to display to the merchant | +| `amount` | `number` | Yes | - | The amount to charge | +| `currencyCode` | `string` | Yes | - | The currency to charge, currently only `"USD"` is supported | +| `interval` | `BillingInterval` | Yes | - | The interval for purchases, one of the BillingInterval enum values | + +`BillingInterval` values: + +- `OneTime` +- `Every30Days` +- `Annual` + +## Checking for payment + +The main method for billing is `Shopify.Billing.check`. +This method will take in the following parameters: + +| Parameter | Type | Required? | Default Value | Notes | +| --------- | --------- | :-------: | :-----------: | ------------------------------------- | +| `session` | `Session` | Yes | - | The `Session` for the current request | +| `isTest` | `boolean` | Yes | - | Whether this is a test purchase | + +And return the following: + +| Parameter | Type | Notes | +| ----------------- | ---------------------- | --------------------------------------------------- | +| `hasPayment` | `boolean` | Whether the store has already paid for the app | +| `confirmationUrl` | `string` / `undefined` | The URL to redirect to if payment is still required | + +Here's a typical example of how to use `check`: + +```ts +const {hasPayment, confirmationUrl} = await Shopify.Billing.check({ + session, + isTest: true, +}); + +if (!hasPayment) { + return redirect(confirmationUrl); +} + +// Proceed with app logic +``` + +## When should the app check for payment + +It's important to note that billing requires a session to access the API, which means that the app must actually be installed before it can request payment. + +With the `check` method, your app can block access to specific endpoints, or to the app as a whole. +If you're gating access to the entire app, you should check for billing: + +1. After OAuth completes, once you get the session back from `Shopify.Auth.validateAuthCallback`. This allows you to ensure billing takes place immediately after the app is installed. + - Note that the merchant may refuse the payment at this point, but the app will already be installed. If your app is using offline tokens, sessions will be unique to a shop, so you can also use the latest session to check for access in full page loads. +1. When validating requests from the frontend. Since the check requires API access, you can only run it in requests that work with `Shopify.Utils.loadCurrentSession`. + +[Back to guide index](../README.md) diff --git a/src/base-types.ts b/src/base-types.ts index 28e205503..90ea163d9 100644 --- a/src/base-types.ts +++ b/src/base-types.ts @@ -1,5 +1,6 @@ import {AuthScopes} from './auth/scopes'; import {SessionStorage} from './auth/session/session_storage'; +import {BillingSettings} from './billing/types'; export interface ContextParams { API_KEY: string; @@ -15,6 +16,7 @@ export interface ContextParams { USER_AGENT_PREFIX?: string; PRIVATE_APP_STOREFRONT_ACCESS_TOKEN?: string; CUSTOM_SHOP_DOMAINS?: (RegExp | string)[]; + BILLING?: BillingSettings; } export enum ApiVersion { diff --git a/src/billing/__tests__/check.test.ts b/src/billing/__tests__/check.test.ts new file mode 100644 index 000000000..b24642528 --- /dev/null +++ b/src/billing/__tests__/check.test.ts @@ -0,0 +1,259 @@ +import {Session} from '../../auth/session/session'; +import {Context} from '../../context'; +import {BillingError} from '../../error'; +import {check} from '../check'; +import {BillingInterval} from '../types'; + +import { + TEST_CHARGE_NAME, + CONFIRMATION_URL, + EMPTY_SUBSCRIPTIONS, + EXISTING_ONE_TIME_PAYMENT, + EXISTING_ONE_TIME_PAYMENT_WITH_PAGINATION, + EXISTING_INACTIVE_ONE_TIME_PAYMENT, + EXISTING_SUBSCRIPTION, + PURCHASE_ONE_TIME_RESPONSE, + PURCHASE_SUBSCRIPTION_RESPONSE, + PURCHASE_ONE_TIME_RESPONSE_WITH_USER_ERRORS, + PURCHASE_SUBSCRIPTION_RESPONSE_WITH_USER_ERRORS, +} from './check_responses'; + +describe('check', () => { + const session = new Session('1234', 'test-shop.myshopify.io', '1234', true); + session.accessToken = 'access-token'; + session.scope = Context.SCOPES.toString(); + + describe('with non-recurring config', () => { + beforeEach(() => { + Context.BILLING = { + amount: 5, + chargeName: TEST_CHARGE_NAME, + currencyCode: 'USD', + interval: BillingInterval.OneTime, + }; + }); + + [true, false].forEach((isTest) => + test(`requests a single payment if there is none (isTest: ${isTest})`, async () => { + fetchMock.mockResponses( + EMPTY_SUBSCRIPTIONS, + PURCHASE_ONE_TIME_RESPONSE, + ); + + const response = await check({ + session, + isTest, + }); + + expect(response).toEqual({ + hasPayment: false, + confirmationUrl: CONFIRMATION_URL, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + body: expect.stringContaining('oneTimePurchases'), + headers: expect.objectContaining({ + 'X-Shopify-Access-Token': session.accessToken, + }), + }), + ); + + const parsedBody = JSON.parse( + fetchMock.mock.calls[1][1]!.body!.toString(), + ); + expect(parsedBody).toMatchObject({ + query: expect.stringContaining('appPurchaseOneTimeCreate'), + variables: expect.objectContaining({test: isTest}), + }); + }), + ); + + test('does not request payment if there is one', async () => { + fetchMock.mockResponses(EXISTING_ONE_TIME_PAYMENT); + + const response = await check({ + session, + isTest: true, + }); + + expect(response).toEqual({ + hasPayment: true, + confirmationUrl: undefined, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + body: expect.stringContaining('oneTimePurchases'), + }), + ); + }); + + test('ignores non-active payments', async () => { + fetchMock.mockResponses( + EXISTING_INACTIVE_ONE_TIME_PAYMENT, + PURCHASE_ONE_TIME_RESPONSE, + ); + + const response = await check({ + session, + isTest: true, + }); + + expect(response).toEqual({ + hasPayment: false, + confirmationUrl: CONFIRMATION_URL, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + body: expect.stringContaining('oneTimePurchases'), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ + body: expect.stringContaining('appPurchaseOneTimeCreate'), + }), + ); + }); + + test('paginates until a payment is found', async () => { + fetchMock.mockResponses(...EXISTING_ONE_TIME_PAYMENT_WITH_PAGINATION); + + const response = await check({ + session, + isTest: true, + }); + + expect(response).toEqual({ + hasPayment: true, + confirmationUrl: undefined, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + + let parsedBody = JSON.parse(fetchMock.mock.calls[0][1]!.body!.toString()); + expect(parsedBody).toMatchObject({ + query: expect.stringContaining('oneTimePurchases'), + variables: expect.objectContaining({endCursor: null}), + }); + + parsedBody = JSON.parse(fetchMock.mock.calls[1][1]!.body!.toString()); + expect(parsedBody).toMatchObject({ + query: expect.stringContaining('oneTimePurchases'), + variables: expect.objectContaining({endCursor: 'end_cursor'}), + }); + }); + + test('throws on userErrors', async () => { + fetchMock.mockResponses( + EMPTY_SUBSCRIPTIONS, + PURCHASE_ONE_TIME_RESPONSE_WITH_USER_ERRORS, + ); + + await expect(() => + check({ + session, + isTest: true, + }), + ).rejects.toThrow(BillingError); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('with recurring config', () => { + beforeEach(() => { + Context.BILLING = { + amount: 5, + chargeName: TEST_CHARGE_NAME, + currencyCode: 'USD', + interval: BillingInterval.Every30Days, + }; + }); + + [true, false].forEach((isTest) => + test(`requests a subscription if there is none (isTest: ${isTest})`, async () => { + fetchMock.mockResponses( + EMPTY_SUBSCRIPTIONS, + PURCHASE_SUBSCRIPTION_RESPONSE, + ); + + const response = await check({ + session, + isTest, + }); + + expect(response).toEqual({ + hasPayment: false, + confirmationUrl: CONFIRMATION_URL, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + body: expect.stringContaining('activeSubscriptions'), + headers: expect.objectContaining({ + 'X-Shopify-Access-Token': session.accessToken, + }), + }), + ); + + const parsedBody = JSON.parse( + fetchMock.mock.calls[1][1]!.body!.toString(), + ); + expect(parsedBody).toMatchObject({ + query: expect.stringContaining('appSubscriptionCreate'), + variables: expect.objectContaining({test: isTest}), + }); + }), + ); + + test('does not request subscription if there is one', async () => { + fetchMock.mockResponses(EXISTING_SUBSCRIPTION); + + const response = await check({ + session, + isTest: true, + }); + + expect(response).toEqual({ + hasPayment: true, + confirmationUrl: undefined, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + body: expect.stringContaining('activeSubscriptions'), + }), + ); + }); + + test('throws on userErrors', async () => { + fetchMock.mockResponses( + EMPTY_SUBSCRIPTIONS, + PURCHASE_SUBSCRIPTION_RESPONSE_WITH_USER_ERRORS, + ); + + await expect(() => + check({ + session, + isTest: true, + }), + ).rejects.toThrow(BillingError); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/billing/__tests__/check_responses.ts b/src/billing/__tests__/check_responses.ts new file mode 100644 index 000000000..4f8c5dcd0 --- /dev/null +++ b/src/billing/__tests__/check_responses.ts @@ -0,0 +1,136 @@ +export const TEST_CHARGE_NAME = 'Shopify app test billing'; +export const CONFIRMATION_URL = 'totally-real-url'; + +export const EMPTY_SUBSCRIPTIONS = JSON.stringify({ + data: { + currentAppInstallation: { + oneTimePurchases: { + edges: [], + pageInfo: {hasNextPage: false, endCursor: null}, + }, + activeSubscriptions: [], + userErrors: [], + }, + }, +}); + +export const EXISTING_ONE_TIME_PAYMENT = JSON.stringify({ + data: { + currentAppInstallation: { + oneTimePurchases: { + edges: [ + { + node: {name: TEST_CHARGE_NAME, test: true, status: 'ACTIVE'}, + }, + ], + pageInfo: {hasNextPage: false, endCursor: null}, + }, + activeSubscriptions: [], + }, + }, +}); + +export const EXISTING_ONE_TIME_PAYMENT_WITH_PAGINATION = [ + JSON.stringify({ + data: { + currentAppInstallation: { + oneTimePurchases: { + edges: [ + { + node: {name: 'some_other_name', test: true, status: 'ACTIVE'}, + }, + ], + pageInfo: {hasNextPage: true, endCursor: 'end_cursor'}, + }, + activeSubscriptions: [], + }, + }, + }), + JSON.stringify({ + data: { + currentAppInstallation: { + oneTimePurchases: { + edges: [ + { + node: { + name: TEST_CHARGE_NAME, + test: true, + status: 'ACTIVE', + }, + }, + ], + pageInfo: {hasNextPage: false, endCursor: null}, + }, + activeSubscriptions: [], + }, + }, + }), +]; + +export const EXISTING_INACTIVE_ONE_TIME_PAYMENT = JSON.stringify({ + data: { + currentAppInstallation: { + oneTimePurchases: { + edges: [ + { + node: { + name: TEST_CHARGE_NAME, + test: true, + status: 'PENDING', + }, + }, + ], + pageInfo: {hasNextPage: false, endCursor: null}, + }, + activeSubscriptions: [], + }, + }, +}); + +export const EXISTING_SUBSCRIPTION = JSON.stringify({ + data: { + currentAppInstallation: { + oneTimePurchases: { + edges: [], + pageInfo: {hasNextPage: false, endCursor: null}, + }, + activeSubscriptions: [{name: TEST_CHARGE_NAME, test: true}], + }, + }, +}); + +export const PURCHASE_ONE_TIME_RESPONSE = JSON.stringify({ + data: { + appPurchaseOneTimeCreate: { + confirmationUrl: CONFIRMATION_URL, + userErrors: [], + }, + }, +}); + +export const PURCHASE_SUBSCRIPTION_RESPONSE = JSON.stringify({ + data: { + appSubscriptionCreate: { + confirmationUrl: CONFIRMATION_URL, + userErrors: [], + }, + }, +}); + +export const PURCHASE_ONE_TIME_RESPONSE_WITH_USER_ERRORS = JSON.stringify({ + data: { + appPurchaseOneTimeCreate: { + confirmationUrl: CONFIRMATION_URL, + userErrors: ['Oops, something went wrong'], + }, + }, +}); + +export const PURCHASE_SUBSCRIPTION_RESPONSE_WITH_USER_ERRORS = JSON.stringify({ + data: { + appSubscriptionCreate: { + confirmationUrl: CONFIRMATION_URL, + userErrors: ['Oops, something went wrong'], + }, + }, +}); diff --git a/src/billing/check.ts b/src/billing/check.ts new file mode 100644 index 000000000..2b5e40534 --- /dev/null +++ b/src/billing/check.ts @@ -0,0 +1,36 @@ +import {Context} from '../context'; +import {SessionInterface} from '../auth/session/types'; + +import {hasActivePayment} from './has_active_payment'; +import {requestPayment} from './request_payment'; + +interface CheckInterface { + session: SessionInterface; + isTest: boolean; +} + +interface CheckReturn { + hasPayment: boolean; + confirmationUrl?: string; +} + +export async function check({ + session, + isTest, +}: CheckInterface): Promise { + if (!Context.BILLING) { + return {hasPayment: true, confirmationUrl: undefined}; + } + + let hasPayment; + let confirmationUrl; + + if (await hasActivePayment(session, isTest)) { + hasPayment = true; + } else { + hasPayment = false; + confirmationUrl = await requestPayment(session, isTest); + } + + return {hasPayment, confirmationUrl}; +} diff --git a/src/billing/has_active_payment.ts b/src/billing/has_active_payment.ts new file mode 100644 index 000000000..fff05a8ec --- /dev/null +++ b/src/billing/has_active_payment.ts @@ -0,0 +1,123 @@ +import {Context} from '../context'; +import {BillingError} from '../error'; +import {GraphqlClient} from '../clients/graphql'; +import {SessionInterface} from '../auth/session/types'; + +import {isRecurring} from './is_recurring'; +import { + ActiveSubscriptions, + CurrentAppInstallations, + OneTimePurchases, +} from './types'; + +export async function hasActivePayment( + session: SessionInterface, + isTest: boolean, +): Promise { + if (isRecurring()) { + return hasActiveSubscription(session, isTest); + } else { + return hasActiveOneTimePurchase(session, isTest); + } +} + +async function hasActiveSubscription( + session: SessionInterface, + isTest: boolean, +): Promise { + if (!Context.BILLING) { + throw new BillingError({ + message: 'Attempted to look for subscriptions without billing configs', + errorData: [], + }); + } + + const client = new GraphqlClient(session.shop, session.accessToken); + + const currentInstallations = await client.query< + CurrentAppInstallations + >({ + data: RECURRING_PURCHASES_QUERY, + }); + + return currentInstallations.body.data.currentAppInstallation.activeSubscriptions.some( + (subscription) => + subscription.name === Context.BILLING!.chargeName && + (isTest || !subscription.test), + ); +} + +async function hasActiveOneTimePurchase( + session: SessionInterface, + isTest: boolean, +): Promise { + if (!Context.BILLING) { + throw new BillingError({ + message: + 'Attempted to look for one time purchases without billing configs', + errorData: [], + }); + } + + const client = new GraphqlClient(session.shop, session.accessToken); + + let installation: OneTimePurchases; + let endCursor = null; + do { + const currentInstallations = await client.query< + CurrentAppInstallations + >({ + data: { + query: ONE_TIME_PURCHASES_QUERY, + variables: {endCursor}, + }, + }); + + installation = currentInstallations.body.data.currentAppInstallation; + if ( + installation.oneTimePurchases.edges.some( + (purchase) => + purchase.node.name === Context.BILLING!.chargeName && + (isTest || !purchase.node.test) && + purchase.node.status === 'ACTIVE', + ) + ) { + return true; + } + + endCursor = installation.oneTimePurchases.pageInfo.endCursor; + } while (installation?.oneTimePurchases.pageInfo.hasNextPage); + + return false; +} + +const RECURRING_PURCHASES_QUERY = ` + query appSubscription { + currentAppInstallation { + activeSubscriptions { + name + test + } + } + } +`; + +const ONE_TIME_PURCHASES_QUERY = ` + query appPurchases($endCursor: String) { + currentAppInstallation { + oneTimePurchases(first: 250, sortKey: CREATED_AT, after: $endCursor) { + edges { + node { + name + test + status + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`; diff --git a/src/billing/index.ts b/src/billing/index.ts new file mode 100644 index 000000000..1d65de43c --- /dev/null +++ b/src/billing/index.ts @@ -0,0 +1,8 @@ +import {check} from './check'; + +const ShopifyBilling = { + check, +}; + +export {ShopifyBilling}; +export default ShopifyBilling; diff --git a/src/billing/is_recurring.ts b/src/billing/is_recurring.ts new file mode 100644 index 000000000..f2ac86a49 --- /dev/null +++ b/src/billing/is_recurring.ts @@ -0,0 +1,20 @@ +import {Context} from '../context'; +import {BillingError} from '../error'; + +import {BillingInterval} from './types'; + +const RECURRING_INTERVALS: BillingInterval[] = [ + BillingInterval.Every30Days, + BillingInterval.Annual, +]; + +export function isRecurring(): boolean { + if (!Context.BILLING) { + throw new BillingError({ + message: 'Attempted to request billing without billing configs', + errorData: [], + }); + } + + return RECURRING_INTERVALS.includes(Context.BILLING.interval!); +} diff --git a/src/billing/request_payment.ts b/src/billing/request_payment.ts new file mode 100644 index 000000000..e74fa1fd5 --- /dev/null +++ b/src/billing/request_payment.ts @@ -0,0 +1,178 @@ +import {GraphqlClient} from '../clients/graphql'; +import {SessionInterface} from '../auth/session/types'; +import {Context} from '../context'; +import {BillingError} from '../error'; +import {buildEmbeddedAppUrl} from '../utils/get-embedded-app-url'; + +import {isRecurring} from './is_recurring'; +import { + RequestResponse, + RecurringPaymentResponse, + SinglePaymentResponse, +} from './types'; + +export async function requestPayment( + session: SessionInterface, + isTest: boolean, +): Promise { + const returnUrl = buildEmbeddedAppUrl( + Buffer.from(`${session.shop}/admin`).toString('base64'), + ); + + let data: RequestResponse; + if (isRecurring()) { + const mutationResponse = await requestRecurringPayment( + session, + returnUrl, + isTest, + ); + data = mutationResponse.data.appSubscriptionCreate; + } else { + const mutationResponse = await requestSinglePayment( + session, + returnUrl, + isTest, + ); + data = mutationResponse.data.appPurchaseOneTimeCreate; + } + + if (data.userErrors?.length) { + throw new BillingError({ + message: 'Error while billing the store', + errorData: data.userErrors, + }); + } + + return data.confirmationUrl; +} + +async function requestRecurringPayment( + session: SessionInterface, + returnUrl: string, + isTest: boolean, +): Promise { + if (!Context.BILLING) { + throw new BillingError({ + message: 'Attempted to request recurring payment without billing configs', + errorData: [], + }); + } + + const client = new GraphqlClient(session.shop, session.accessToken); + + const mutationResponse = await client.query({ + data: { + query: RECURRING_PURCHASE_MUTATION, + variables: { + name: Context.BILLING.chargeName, + lineItems: [ + { + plan: { + appRecurringPricingDetails: { + interval: Context.BILLING.interval, + price: { + amount: Context.BILLING.amount, + currencyCode: Context.BILLING.currencyCode, + }, + }, + }, + }, + ], + returnUrl, + test: isTest, + }, + }, + }); + + if (mutationResponse.body.errors?.length) { + throw new BillingError({ + message: 'Error while billing the store', + errorData: mutationResponse.body.errors, + }); + } + + return mutationResponse.body; +} + +async function requestSinglePayment( + session: SessionInterface, + returnUrl: string, + isTest: boolean, +): Promise { + if (!Context.BILLING) { + throw new BillingError({ + message: 'Attempted to request single payment without billing configs', + errorData: [], + }); + } + + const client = new GraphqlClient(session.shop, session.accessToken); + + const mutationResponse = await client.query({ + data: { + query: ONE_TIME_PURCHASE_MUTATION, + variables: { + name: Context.BILLING.chargeName, + price: { + amount: Context.BILLING.amount, + currencyCode: Context.BILLING.currencyCode, + }, + returnUrl, + test: isTest, + }, + }, + }); + + if (mutationResponse.body.errors?.length) { + throw new BillingError({ + message: 'Error while billing the store', + errorData: mutationResponse.body.errors, + }); + } + + return mutationResponse.body; +} + +const RECURRING_PURCHASE_MUTATION = ` + mutation test( + $name: String! + $lineItems: [AppSubscriptionLineItemInput!]! + $returnUrl: URL! + $test: Boolean + ) { + appSubscriptionCreate( + name: $name + lineItems: $lineItems + returnUrl: $returnUrl + test: $test + ) { + confirmationUrl + userErrors { + field + message + } + } + } +`; + +const ONE_TIME_PURCHASE_MUTATION = ` + mutation test( + $name: String! + $price: MoneyInput! + $returnUrl: URL! + $test: Boolean + ) { + appPurchaseOneTimeCreate( + name: $name + price: $price + returnUrl: $returnUrl + test: $test + ) { + confirmationUrl + userErrors { + field + message + } + } + } +`; diff --git a/src/billing/types.ts b/src/billing/types.ts new file mode 100644 index 000000000..a591d5d23 --- /dev/null +++ b/src/billing/types.ts @@ -0,0 +1,65 @@ +export enum BillingInterval { + OneTime = 'ONE_TIME', + Every30Days = 'EVERY_30_DAYS', + Annual = 'ANNUAL', +} + +export interface BillingSettings { + chargeName: string; + amount: number; + currencyCode: string; + interval: BillingInterval; +} + +export interface ActiveSubscription { + name: string; + test: boolean; +} + +export interface ActiveSubscriptions { + activeSubscriptions: ActiveSubscription[]; +} + +export interface OneTimePurchase { + name: string; + test: boolean; + status: string; +} + +export interface OneTimePurchases { + oneTimePurchases: { + edges: { + node: OneTimePurchase; + }[]; + pageInfo: { + endCursor: string; + hasNextPage: boolean; + }; + }; +} + +export interface CurrentAppInstallations { + userErrors: string[]; + data: { + currentAppInstallation: T; + }; +} + +export interface RequestResponse { + userErrors: string[]; + confirmationUrl: string; +} + +export interface RecurringPaymentResponse { + data: { + appSubscriptionCreate: RequestResponse; + }; + errors?: string[]; +} + +export interface SinglePaymentResponse { + data: { + appPurchaseOneTimeCreate: RequestResponse; + }; + errors?: string[]; +} diff --git a/src/context.ts b/src/context.ts index 40dbddb49..080f341d1 100644 --- a/src/context.ts +++ b/src/context.ts @@ -108,6 +108,10 @@ const Context: ContextInterface = { if (params.CUSTOM_SHOP_DOMAINS) { this.CUSTOM_SHOP_DOMAINS = params.CUSTOM_SHOP_DOMAINS; } + + if (params.BILLING) { + this.BILLING = params.BILLING; + } }, throwIfUninitialized(): void { diff --git a/src/error.ts b/src/error.ts index 4b9f50679..3ee4cfbf0 100644 --- a/src/error.ts +++ b/src/error.ts @@ -92,3 +92,14 @@ export class MissingRequiredArgument extends ShopifyError {} export class UnsupportedClientType extends ShopifyError {} export class InvalidRequestError extends ShopifyError {} + +export class BillingError extends ShopifyError { + readonly errorData: any; + + public constructor({message, errorData}: {message: string; errorData: any}) { + super(message); + + this.message = message; + this.errorData = errorData; + } +} diff --git a/src/index.ts b/src/index.ts index 680838fdf..e6f082bb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import {Context} from './context'; import * as ShopifyErrors from './error'; import ShopifyAuth from './auth/oauth'; +import ShopifyBilling from './billing'; import ShopifySession from './auth/session'; import ShopifyClients from './clients'; import ShopifyUtils from './utils'; @@ -9,6 +10,7 @@ import ShopifyWebhooks from './webhooks'; export const Shopify = { Context, Auth: ShopifyAuth, + Billing: ShopifyBilling, Session: ShopifySession, Clients: ShopifyClients, Utils: ShopifyUtils, diff --git a/src/types.ts b/src/types.ts index d40776c99..9db6f3704 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ export * from './base-types'; export * from './auth/types'; +export * from './billing/types'; export * from './clients/types'; export * from './utils/types'; export * from './webhooks/types'; diff --git a/src/utils/__tests__/get-embedded-app-url.test.ts b/src/utils/__tests__/get-embedded-app-url.test.ts index 262ddd594..0c4406f59 100644 --- a/src/utils/__tests__/get-embedded-app-url.test.ts +++ b/src/utils/__tests__/get-embedded-app-url.test.ts @@ -48,7 +48,7 @@ describe('getEmbeddedAppUrl', () => { } as http.IncomingMessage; expect(() => getEmbeddedAppUrl(req)).toThrow( - ShopifyErrors.InvalidRequestError, + ShopifyErrors.InvalidHostError, ); }); diff --git a/src/utils/get-embedded-app-url.ts b/src/utils/get-embedded-app-url.ts index 3fc41f839..688756ade 100644 --- a/src/utils/get-embedded-app-url.ts +++ b/src/utils/get-embedded-app-url.ts @@ -28,12 +28,18 @@ export default function getEmbeddedAppUrl( const url = new URL(request.url, `https://${request.headers.host}`); const host = url.searchParams.get('host'); - if (typeof host !== 'string' || !sanitizeHost(host)) { + if (typeof host !== 'string') { throw new ShopifyErrors.InvalidRequestError( 'Request does not contain a host query parameter', ); } + return buildEmbeddedAppUrl(host); +} + +export function buildEmbeddedAppUrl(host: string): string { + sanitizeHost(host, true); + const decodedHost = Buffer.from(host, 'base64').toString(); return `https://${decodedHost}/apps/${Context.API_KEY}`; diff --git a/src/utils/setup-jest.ts b/src/utils/setup-jest.ts index 1dbba9f5c..47631ea36 100644 --- a/src/utils/setup-jest.ts +++ b/src/utils/setup-jest.ts @@ -20,6 +20,7 @@ beforeEach(() => { IS_PRIVATE_APP: false, SESSION_STORAGE: new MemorySessionStorage(), CUSTOM_SHOP_DOMAINS: undefined, + BILLING: undefined, }); fetchMock.mockReset();