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,
});
},
};