Source: shopify/index.js

import { CACHE } from 'cpb-api';
import ShopIdService from 'cpb-api/shop/shopId.js';
import ShopifyToken from 'cpb-api/subscription/shopify.js';
import { isObject, makeChunks, stringifyJSON } from 'cpb-common';
import * as Datastore from 'cpb-datastore';
import ShopifyConnect from 'shopify-api-node';
import Charge from './charge/index.js';
import Product from './product/index.js';
//import Webhooks from './webhook/index.js';

const DEFAULT_PLAN_NAME = 'default',
  KIND_SHOPIFY_SHOPS = 'shopify_shops';

export { Charge };
/**
 * @exports module:cpb-shopify.Shopify
 */
export default {
  Charge,
  Product,
  //  Webhooks,
  ShopIdService,
  settings: {
    /**
     * @typedef {object} ShopifyConnectionOptions
     * @property {number} [timeout=180000] - request timeout
     * @property {boolean} [autoLimit=true] - limit api requests
     * @property {function} [stringifyJson=stringifyJSON] -  function used instead of `JSON.stringify`
     * @property {boolean} [presentmentPrices=true] - fetch representment prices
     */
    /**
     * @type ShopifyConnectionOptions
     */
    connect: {
      timeout: 180000,
      autoLimit: true,
      stringifyJson: stringifyJSON,
      presentmentPrices: true,
      apiVersion: '2022-01',
    },
  },
  /**
   * ### Create Shopify API instance and filestore it in `CACHE.connect`
   * @param {!string} shopName
   * @param {?(object|string)} [accessToken]
   * @param {?ShopifyConnectionOptions} connectionOptions
   * @returns {StoreConnectionInstance}
   */
  async connect({ shopName, accessToken }, connectionOptions = {}) {
    //    console.info('[shopify/connect]', { shopName, accessToken });
    if (!shopName) throw new TypeError('missing shopName');
    if (CACHE.connect.has(shopName)) {
      console.info(`[cpb-shopify/index/connect][${shopName}] returning from CACHE.connect`);
      return CACHE.connect.get(shopName);
    }

    accessToken = accessToken || (await this.getStoredToken(shopName));
    if (accessToken && accessToken.token) accessToken = accessToken.token;
    if (!accessToken) throw new Error(`Subscriptions.Shopify.connect[${shopName}] missing accessToken`);

    CACHE.connect.set(shopName, new ShopifyConnect({ shopName, accessToken, ...this.settings.connect, ...connectionOptions }));
    CACHE.connect.get(shopName).on('callLimits', onShopifyCallLimits);

    /**
     *  executes every time request is made; has shopify call limits
     * @param {number} remaining - remaining calls
     * @param {number} current - current utilization
     * @param {number} max - max requests per filestore
     */
    function onShopifyCallLimits({ remaining, current, max }) {
      console.debug(`[${shopName}]onShopifyCallLimits`, ...arguments);
      if (!remaining) console.error('callLimits exceeded', { shopName, remaining, current, max });
    }

    return CACHE.connect.get(shopName);
  },

  /**
   * ### Get and Save Shopify Store Information
   * implements `package:shopify-api-node.shop.get()`
   * @param {string} shopName
   * @param {boolean|number} [fetch=false] - indicates whether  to perform the fetch from Shopify API instead of returning the datastore record
   * @returns {Promise<ShopifyShop|undefined>} shopData - shop info with added properties of
   *  * `datastore_inserted_at`
   *  * `datastore_updated_at`
   *  * `shopName` - third-level domain name of the shop *shopName*[.myshopify.com]
   *  * `shopID` - Shopify Shop ID
   * @example Response
   * {
   *   id: 55028482214,
   *   name: 'Zap Moto',
   *   email: 'sales@zapmoto.com.au',
   *   domain: 'zapmoto.com.au',
   *   province: 'Queensland',
   *   country: 'AU',
   *   address1: '40 Waterloo Street',
   *   zip: '4006',
   *   city: 'Newstead',
   *   source: 'buildify',
   *   phone: '0424977731',
   *   latitude: -27.4481248,
   *   longitude: 153.0444463,
   *   primary_locale: 'en',
   *   address2: '',
   *   created_at: '2021-03-02T20:16:39+10:00',
   *   updated_at: '2021-12-08T21:21:17+10:00',
   *   country_code: 'AU',
   *   country_name: 'Australia',
   *   currency: 'AUD',
   *   customer_email: 'Sales@zapmoto.com.au',
   *   timezone: '(GMT+10:00) Australia/Brisbane',
   *   iana_timezone: 'Australia/Brisbane',
   *   shop_owner: 'Paul Halhead',
   *   money_format: '${{amount}}',
   *   money_with_currency_format: '${{amount}} AUD',
   *   weight_unit: 'kg',
   *   province_code: 'QLD',
   *   taxes_included: true,
   *   auto_configure_tax_inclusivity: false,
   *   tax_shipping: false,
   *   county_taxes: true,
   *   plan_display_name: 'Basic Shopify',
   *   plan_name: 'basic',
   *   has_discounts: false,
   *   has_gift_cards: false,
   *   myshopify_domain: 'zap-moto.myshopify.com',
   *   google_apps_domain: null,
   *   google_apps_login_enabled: null,
   *   money_in_emails_format: '${{amount}}',
   *   money_with_currency_in_emails_format: '${{amount}} AUD',
   *   eligible_for_payments: true,
   *   requires_extra_payments_agreement: false,
   *   password_enabled: false,
   *   has_storefront: true,
   *   eligible_for_card_reader_giveaway: false,
   *   finances: true,
   *   primary_location_id: 61045997734,
   *   cookie_consent_level: 'implicit',
   *   visitor_tracking_consent_preference: 'allow_all',
   *   checkout_api_supported: true,
   *   multi_location_enabled: true,
   *   setup_required: false,
   *   pre_launch_enabled: false,
   *   enabled_presentment_currencies: [ 'AUD' ],
   *   force_ssl: true,
   *   shopName: 'zap-moto',
   *   shopID: 55028482214
   * }
   */
  async getShopData(shopName, fetch) {
    if (!shopName) throw new TypeError('!shopName');
    let latestData, connection;
    if (fetch) {
      console.debug(`[shopify/getShopData][${shopName}] getting new data`);
      try {
        connection = await this.connect({ shopName });
      } catch (error) {
        console.error(`[shopify/getShopData][${shopName}] ERROR_NO_SHOPIFY_CONNECTION`, error);
      }

      try {
        latestData = await connection.shop.get();
      } catch (error) {
        console.error(`[shopify/index/getShopData][${shopName}] ERROR_GET_SHOPDATA: ${error.code} ${error.name}
        ${error.message}
        ############################################################
        `);
        if (error.code === 'ETIMEDOUT') latestData = await connection.shop.get();
      }

      if (latestData) {
        latestData.shopID = latestData.id;
        latestData.shopName = shopName;
        latestData.datastore_inserted_at = new Date();
        //        latestData._DEBUG = 'shopify';
        CACHE.shopData.set(shopName, latestData);
        this.saveShopData(latestData).catch(console.error);
        return latestData;
      }
    }

    if (CACHE.shopData.has(shopName)) return CACHE.shopData.get(shopName);
    const storedData = await this.getStoredShopData(shopName);
    if (storedData) {
      CACHE.shopData.set(shopName, storedData);
      return storedData;
    }
    return undefined;
  },

  /**
   * ### Get ShopData from Datastore
   * @param {!string|number} shopNameOrId
   * @param cached
   * @return {Promise<undefined|*>}
   */
  async getStoredShopData(shopNameOrId, cached) {
    if (!shopNameOrId) throw new TypeError('!shopNameOrId');
    const { name: shopName } = await ShopIdService.getIdName(shopNameOrId);
    if (!shopName) throw new TypeError('!shopName');

    if (cached && CACHE.shopData.has(shopName)) return CACHE.shopData.get(shopName);

    const data = await Datastore.query({
      kind: KIND_SHOPIFY_SHOPS,
      filter: { shopName },
      useEmulator: process.env.USE_EMULATOR_DATASTORE,
    });

    if (data.length) {
      const propertyKey = Datastore.datastore.key([KIND_SHOPIFY_SHOPS, shopName]);
      //        unneededRecords = data.filter(d => !Datastore.Keys.compare(d[Datastore.datastore.KEY], propertyKey));

      let properRecord = data.find(d => Datastore.Keys.compare(d[Datastore.datastore.KEY], propertyKey));
      if (!properRecord) {
        console.warn(`[${shopName}] resaving shopData with the new key`);
        properRecord = data[0];
        properRecord.shopName = shopName;
        await this.saveShopData(properRecord);
      }

      if (properRecord) CACHE.shopData.set(shopName, properRecord);

      //      const unneededKeys = data.filter(d => !Datastore.Keys.compare(d[Datastore.datastore.KEY], propertyKey)).map(r => r[Datastore.datastore.KEY]);
      //      console.info(`[shopify/index][${shopName}] removing excessive records`, unneededKeys.length);
      //      for (const keys of makeChunks(unneededKeys, 25)) Datastore.delete({ keys }).catch(console.error);
      return properRecord;
    }
    return undefined;
  },
  /**
   *  ### Save Shopify shopData to the datastore
   * @param data
   * @return {Promise<void|*>}
   */
  async saveShopData(data) {
    if (!data || !isObject(data)) throw new TypeError('!data');
    if (!data.id) throw new TypeError('!data.id');
    if (!data.shopName) throw new TypeError('!data.shopName');

    data.shopID = data.id;
    data.datastore_updated_at = new Date();
    //    data._DEBUG = 'saveShopData';
    delete data.datastore_created_at;

    const propertyKey = Datastore.datastore.key([KIND_SHOPIFY_SHOPS, data.shopName]),
      savedData = await Datastore.query({
        kind: KIND_SHOPIFY_SHOPS,
        filter: { shopName: data.shopName },
        useEmulator: process.env.USE_EMULATOR_DATASTORE,
      }),
      // properRecord = savedData.find(d => Datastore.Keys.compare(d[Datastore.datastore.KEY], propertyKey)),
      unneededKeys = savedData.filter(d => !Datastore.Keys.compare(d[Datastore.datastore.KEY], propertyKey)).map(r => r[Datastore.datastore.KEY]);
    console.info(`[shopify/index][${data.shopName}] removing excessive records`, unneededKeys.length);
    for (const keys of makeChunks(unneededKeys, 25)) Datastore.delete({ keys }).catch(console.error);

    return await Datastore.save({
      kind: KIND_SHOPIFY_SHOPS,
      key: Datastore.datastore.key([KIND_SHOPIFY_SHOPS, data.shopName]),
      data,
      useEmulator: process.env.USE_EMULATOR_DATASTORE,
    });
  },
  /**
   * get  shopify auth token from the datastore
   * @param {string|number} shopNameOrId
   * @returns {Promise<{shopNames: Array(), ids: Array(), tokens: Array()}>}
   */
  async getStoredToken(shopName) {
    if (!shopName) throw new TypeError('!shopName');
    return await ShopifyToken.getToken({ shopName });
  },

  async saveToken(data) {
    if (!data || !isObject(data)) throw new TypeError('!data');
    data.datastore_updated_at = new Date();
    data.datastore_inserted_at = data.datastore_inserted_at || data.datastore_updated_at;
    data.planName = data.planName || DEFAULT_PLAN_NAME;
    delete data.values;
    delete data.datastore_created_at;
    const key = Datastore.datastore.key([ShopifyToken.settings.kind, data.shopName]);
    if (!data.token) throw new Error('[shopify/saveToken] NO_TOKEN');

    return await Datastore.save({
      kind: ShopifyToken.settings.kind,
      data,
      key,
      useEmulator: process.env.USE_EMULATOR_DATASTORE,
    });
  },
};