Skip to content

Commit c722f32

Browse files
authored
Merge pull request #5 from Foxy/feat/shiptheory
Feat/shiptheory
2 parents 2348303 + 409306a commit c722f32

File tree

12 files changed

+881
-1
lines changed

12 files changed

+881
-1
lines changed

config.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @file Manages the configuration settings
3+
*/
4+
5+
/**
6+
* @param {string} envVar the environment variable to get
7+
* @returns {string} the environment variable value
8+
*/
9+
function env(envVar) {
10+
return process.env[envVar];
11+
}
12+
const config = {
13+
datastore: {
14+
credentials: env('FOXY_DATASTORE_CREDENTIALS'),
15+
error: {
16+
insufficientInventory: env('FOXY_ERROR_INSUFFICIENT_INVENTORY') || env('FX_ERROR_INSUFFICIENT_INVENTORY'),
17+
priceMismatch: env('FOXY_ERROR_PRICE_MISMATCH') || env('FX_ERROR_PRICE_MISMATCH')
18+
},
19+
field: {
20+
code: env('FOXY_FIELD_CODE') || env('FX_FIELD_CODE'),
21+
inventory: env('FOXY_FIELD_INVENTORY') || env('FX_FIELD_INVENTORY'),
22+
price: env('FOXY_FIELD_PRICE') || env('FX_FIELD_PRICE')
23+
},
24+
provider: {
25+
orderDesk: {
26+
apiKey: env("FOXY_ORDERDESK_API_KEY"),
27+
storeId: env("FOXY_ORDERDESK_STORE_ID"),
28+
},
29+
webflow: {
30+
token: env('FOXY_WEBFLOW_TOKEN') || env('WEBFLOW_TOKEN'),
31+
}
32+
},
33+
skipUpdate: {
34+
inventory: env('FOXY_SKIP_INVENTORY_UPDATE_CODES')
35+
},
36+
skipValidation: {
37+
inventory: env('FOXY_SKIP_INVENTORY_CODES') || env('FX_SKIP_INVENTORY_CODES'),
38+
price: env('FOXY_SKIP_PRICE_CODES') || env('FX_SKIP_PRICE_CODES')
39+
},
40+
},
41+
default: {
42+
autoshipFrequency: env('FOXY_DEFAULT_AUTOSHIP_FREQUENCY') || env('DEFAULT_AUTOSHIP_FREQUENCY')
43+
},
44+
foxy: {
45+
api: {
46+
clientId: env('FOXY_API_CLIENT_ID'),
47+
clientSecret: env('FOXY_API_CLIENT_SECRET'),
48+
refreshToken: env('FOXY_API_REFRESH_TOKEN')
49+
},
50+
webhook: {
51+
encryptionKey: env('FOXY_WEBHOOK_ENCRYPTION_KEY'),
52+
}
53+
},
54+
idevAffiliate: {
55+
apiUrl: env('FOXY_IDEV_API_URL') || env('IDEV_API_URL'),
56+
secretKey: env('FOXY_IDEV_SECRET_KEY') || env('IDEV_SECRET_KEY'),
57+
},
58+
}
59+
60+
module.exports = config;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "Run serverless functions on Netlify to work with the Foxy.io hypermedia API.",
55
"private": false,
66
"scripts": {
7-
"test": "nyc mocha \"src/**/*.test.js\"",
7+
"test": "nyc mocha \"test/**/*.test.js\"",
88
"start": "netlify dev",
99
"start-http": "http-server ./src/functions --port 9000 -c-1"
1010
},

src/foxy/DataStoreBase.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
const config = require("../../config.js");
2+
3+
class DataStoreBase {
4+
5+
skipUpdate = {
6+
inventory: []
7+
}
8+
9+
constructor() {
10+
const abstractMethods = [
11+
'fetchItems',
12+
'getCredentials',
13+
'setCredentials'
14+
];
15+
const unimplemented = abstractMethods.filter(
16+
m => typeof this[m] !== "function"
17+
);
18+
if (unimplemented.length) {
19+
throw new TypeError(unimplemented.join(',') + " must be overriden");
20+
}
21+
this.skipFromEnv();
22+
}
23+
24+
/**
25+
* Autoconfigures the instance to skip the validation of prices and inventory
26+
* of items with codes listed in the configured environment variables.
27+
*/
28+
skipFromEnv() {
29+
if (config.datastore.skipUpdate.inventory === '__ALL__') {
30+
this.skipUpdate.inventory.all = true;
31+
}
32+
this.skipUpdate.inventory.concat(
33+
(config.datastore.skipUpdate.inventory || '').split(',')
34+
);
35+
}
36+
37+
/**
38+
* Retrieves items from the DataStore
39+
*
40+
* @abstract
41+
* @returns {Promise<Array<Object>>} a promise to the items.
42+
*/
43+
fetchItems() {
44+
throw new Error();
45+
}
46+
47+
/**
48+
* Retrieves the credentials needed for the DataStore to work.
49+
*
50+
* This method is needed to ensure testability, removing the
51+
* datastore credentials from the methods to be tested.
52+
*
53+
* @abstract
54+
* @returns {Object|string} credentials needed for the integration;
55+
*/
56+
getCredentials() {
57+
throw new Error();
58+
}
59+
60+
/**
61+
* Sets the credentials needed for the DataStore to work.
62+
*
63+
* This method is needed to ensure testability, removing the
64+
* datastore credentials from the methods to be tested.
65+
*
66+
* @param {Object|string} credentials needed for the integration;
67+
* @abstract
68+
*/
69+
setCredentials(credentials) {
70+
throw new Error();
71+
}
72+
73+
}
74+
75+
module.exports = DataStoreBase;

src/foxy/FoxyWebhook.js

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
const FoxySdk = require('@foxy.io/sdk');
2+
const config = require('../../config.js');
3+
4+
5+
/**
6+
* @typedef {Object} PrepaymentPayload
7+
* @property {Object} _links
8+
* @property {PrepaymentEmbedded} _embedded
9+
* @property {string} customer_uri
10+
* @property {string} template_set_uri
11+
* @property {string} language
12+
* @property {string} locale_code
13+
* @property {string} customer_ip
14+
* @property {string} ip_country
15+
* @property {string} session_name
16+
* @property {string} session_id
17+
* @property {string} total_item_price
18+
* @property {string} total_tax
19+
* @property {string} total_shipping
20+
* @property {string} total_future_shipping
21+
* @property {string} total_order
22+
* @property {string} date_created
23+
* @property {string} date_modified
24+
*/
25+
26+
/**
27+
* @typedef {Object} PrepaymentEmbedded
28+
* @property {Array<PrepaymentItem>} fx:items
29+
* @property {Array} fx:discounts
30+
* @property {Array} fx:custom_fields
31+
* @property {Array} fx:shipmet
32+
* @property {Array} fx:customer
33+
*/
34+
35+
/**
36+
* @typedef {Object} PrepaymentItem
37+
* @property {string} item_category_uri
38+
* @property {string} name
39+
* @property {string} price
40+
* @property {string} quantity
41+
* @property {string} quantity_min
42+
* @property {string} quantity_max
43+
* @property {string} weight
44+
* @property {string} code
45+
* @property {string} parent_code
46+
* @property {string} discount_name
47+
* @property {string} discount_type
48+
* @property {string} discount_details
49+
* @property {string} subscription_frequency
50+
* @property {string} subscription_start_date
51+
* @property {string} subscription_next_transaction_date
52+
* @property {string} subscription_end_date
53+
* @property {string} is_future_line_item
54+
* @property {string} shipto
55+
* @property {string} url
56+
* @property {string} image
57+
* @property {string} length
58+
* @property {string} width
59+
* @property {string} height
60+
* @property {string} expires
61+
* @property {string} date_created
62+
* @property {string} date_modified
63+
*/
64+
65+
/**
66+
* Retrieves the items from the payload
67+
*
68+
* @param {PrepaymentPayload} payload to provide the items.
69+
* @returns {Array<PrepaymentItem>} an array of items from this payload
70+
*/
71+
function getItems(payload) {
72+
try {
73+
return payload._embedded['fx:items'] || [];
74+
} catch (e) {
75+
return [];
76+
}
77+
}
78+
79+
/**
80+
* Builds a response as expected by the prepayment webhook.
81+
*
82+
* @param {string} details about the error, if it happened.
83+
* @param {number} code the HTTP status code
84+
* @returns {{body: string, statusCode: number}} a string to be used as the body of the response.
85+
*/
86+
function response(details="", code=200) {
87+
if (code !== 200 && (!details || details.match(/^\s*$/))) {
88+
throw new Error("An error response needs to specify details.");
89+
}
90+
return {
91+
body: JSON.stringify({
92+
details: details || "",
93+
ok: details === ""
94+
}),
95+
statusCode: code
96+
}
97+
}
98+
99+
/**
100+
* Creates a details message about insufficient inventory.
101+
*
102+
* @param {Array} pairs with insufficient inventory.
103+
* @returns {string} a configurable message.
104+
*/
105+
function messageInsufficientInventory(pairs) {
106+
if (!pairs.length) return '';
107+
const message = config.datastore.error.insufficientInventory ||
108+
'Insufficient inventory for these items';
109+
return message + ' ' + pairs
110+
.map(p => `${p[1].name}: only ${p[1].inventory} available`).join(';')
111+
}
112+
113+
/**
114+
* Creates a details message about invalid price.
115+
*
116+
* @param {Array} pairs with invalid price.
117+
* @returns {string} a configurable message.
118+
*/
119+
function messagePriceMismatch(pairs) {
120+
if (!pairs.length) return '';
121+
const message = config.datastore.error.priceMismatch ||
122+
'Prices do not match:';
123+
return message + ' ' + pairs
124+
.map(p => p[0].name)
125+
.join(', ');
126+
}
127+
128+
/**
129+
* Verifies a Foxy Signature in a Webhook.
130+
*
131+
* @param {string} payload received in the Foxy Webhook request.
132+
* @param {string} signature received in the Foxy Webhook request.
133+
* @param {string} key to be used to verify the signature.
134+
* @returns {boolean} the signature is valid
135+
*/
136+
function validSignature(payload, signature, key) {
137+
try {
138+
return FoxySdk.Backend.verifyWebhookSignature({ key, payload, signature });
139+
} catch (e) {
140+
console.error(e);
141+
return false;
142+
}
143+
}
144+
145+
/**
146+
* Verifies the signature of a Foxy Webhook Request.
147+
*
148+
* @param {Object} req the request with the signature to be verified
149+
* @returns {boolean} the signature is valid
150+
*/
151+
function verifyWebhookSignature(req) {
152+
const foxyEvent = req.headers['foxy-webhook-event'];
153+
const signature = req.headers['foxy-webhook-signature'];
154+
if (foxyEvent === 'validation/payment') {
155+
if (!signature) {
156+
return true;
157+
}
158+
}
159+
const key = config.foxy.webhook.encryptionKey;
160+
const payload = req.body;
161+
return validSignature(payload, signature, key);
162+
}
163+
164+
/**
165+
* Validates a Foxy request.
166+
*
167+
* It must be a Signed POST request with content-type application/json.
168+
*
169+
* @param {Request} requestEvent to be evaluated as valid.
170+
* @returns {string|boolean} the error with this request.
171+
*/
172+
function validFoxyRequest(requestEvent) {
173+
let err = false;
174+
if (!requestEvent) {
175+
err = 'Request Event does not Exist';
176+
} else if (!requestEvent.body) {
177+
err = 'Empty request.';
178+
} else if (!requestEvent.httpMethod || requestEvent.httpMethod !== 'POST') {
179+
err = 'Method not allowed';
180+
} else if (requestEvent.headers['content-type'] !== 'application/json') {
181+
err = 'Content type should be application/json';
182+
} else if (!verifyWebhookSignature(requestEvent)) {
183+
err = 'Forbidden';
184+
}
185+
try {
186+
JSON.parse(requestEvent.body);
187+
} catch (e) {
188+
err = 'Payload is not valid JSON.';
189+
}
190+
return err;
191+
}
192+
193+
module.exports = {
194+
getItems,
195+
messageInsufficientInventory,
196+
messagePriceMismatch,
197+
response,
198+
validFoxyRequest,
199+
validSignature,
200+
verifyWebhookSignature,
201+
}
202+

0 commit comments

Comments
 (0)