diff --git a/config.js b/config.js index 587bcc9..73cf5a5 100644 --- a/config.js +++ b/config.js @@ -13,13 +13,16 @@ function env(envVar) { const config = { datastore: { error: { - insufficientInventory: env('FOXY_ERROR_INSUFFICIENT_INVENTORY') || env('FX_ERROR_INSUFFICIENT_INVENTORY'), - priceMismatch: env('FOXY_ERROR_PRICE_MISMATCH') || env('FX_ERROR_PRICE_MISMATCH') + insufficientInventory: + env("FOXY_ERROR_INSUFFICIENT_INVENTORY") || + env("FX_ERROR_INSUFFICIENT_INVENTORY"), + priceMismatch: + env("FOXY_ERROR_PRICE_MISMATCH") || env("FX_ERROR_PRICE_MISMATCH"), }, field: { - code: env('FOXY_FIELD_CODE') || env('FX_FIELD_CODE'), - inventory: env('FOXY_FIELD_INVENTORY') || env('FX_FIELD_INVENTORY'), - price: env('FOXY_FIELD_PRICE') || env('FX_FIELD_PRICE') + code: env("FOXY_FIELD_CODE") || env("FX_FIELD_CODE"), + inventory: env("FOXY_FIELD_INVENTORY") || env("FX_FIELD_INVENTORY"), + price: env("FOXY_FIELD_PRICE") || env("FX_FIELD_PRICE"), }, provider: { orderDesk: { @@ -27,42 +30,47 @@ const config = { storeId: env("FOXY_ORDERDESK_STORE_ID"), }, webflow: { - collection: env('FOXY_WEBFLOW_COLLECTION'), - token: env('FOXY_WEBFLOW_TOKEN') || env('WEBFLOW_TOKEN'), - } + collection: env("FOXY_WEBFLOW_COLLECTION"), + token: env("FOXY_WEBFLOW_TOKEN") || env("WEBFLOW_TOKEN"), + }, }, skipUpdate: { - inventory: env('FOXY_SKIP_INVENTORY_UPDATE_CODES') + inventory: env("FOXY_SKIP_INVENTORY_UPDATE_CODES"), }, skipValidation: { - inventory: env('FOXY_SKIP_INVENTORY_CODES') || env('FX_SKIP_INVENTORY_CODES'), - price: env('FOXY_SKIP_PRICE_CODES') || env('FX_SKIP_PRICE_CODES'), - updateinfo: env('FOXY_SKIP_UPDATEINFO_NAME') + inventory: + env("FOXY_SKIP_INVENTORY_CODES") || env("FX_SKIP_INVENTORY_CODES"), + price: env("FOXY_SKIP_PRICE_CODES") || env("FX_SKIP_PRICE_CODES"), + updateinfo: env("FOXY_SKIP_UPDATEINFO_NAME"), }, }, default: { - autoshipFrequency: env('FOXY_DEFAULT_AUTOSHIP_FREQUENCY') || env('DEFAULT_AUTOSHIP_FREQUENCY') + autoshipFrequency: + env("FOXY_DEFAULT_AUTOSHIP_FREQUENCY") || + env("DEFAULT_AUTOSHIP_FREQUENCY"), }, foxy: { api: { - clientId: env('FOXY_API_CLIENT_ID'), - clientSecret: env('FOXY_API_CLIENT_SECRET'), - refreshToken: env('FOXY_API_REFRESH_TOKEN') + clientId: env("FOXY_API_CLIENT_ID"), + clientSecret: env("FOXY_API_CLIENT_SECRET"), + refreshToken: env("FOXY_API_REFRESH_TOKEN"), }, webhook: { - encryptionKey: env('FOXY_WEBHOOK_ENCRYPTION_KEY'), - } + encryptionKey: env("FOXY_WEBHOOK_ENCRYPTION_KEY"), + }, }, idevAffiliate: { - apiUrl: env('FOXY_IDEV_API_URL') || env('IDEV_API_URL'), - secretKey: env('FOXY_IDEV_SECRET_KEY') || env('IDEV_SECRET_KEY'), + apiUrl: env("FOXY_IDEV_API_URL") || env("IDEV_API_URL"), + secretKey: env("FOXY_IDEV_SECRET_KEY") || env("IDEV_SECRET_KEY"), + }, + lune: { + apiKey: env("LUNE_API_KEY"), }, vatlayer: { - accessKey: env('VATLAYER_ACCESS_KEY'), - } -} - + accessKey: env("VATLAYER_ACCESS_KEY"), + }, +}; module.exports = { - config -} + config, +}; diff --git a/src/functions/lune-integration/README.md b/src/functions/lune-integration/README.md new file mode 100644 index 0000000..bcad3ec --- /dev/null +++ b/src/functions/lune-integration/README.md @@ -0,0 +1,59 @@ +# Lune Integration for CO2 Offset Order creation + +This Foxy.io-Lune integration provides you with: + +- a **custom shipping code** to add to the Custom Shipping Code feature on your Foxy Store Admin [Shipping Page](https://admin.foxycart.com/admin.php?ThisAction=ShippingSetup), for getting live shipping rates with CO2 offset estimates. The code is on the `custom-shipping-code.js` file under this folder. +- a **transaction webhook** to create an CO2 offset Order in Lune when a transaction is created. + +## Usage + +### Overview + +- Sign up with Lune, and get an API key. +- Create Foxy OAuth Client Integration on the [Foxy Admin Integrations page](https://admin.foxycart.com/admin.php?ThisAction=AddIntegration), and save the credentials. +- Add the custom shipping code on your Foxy Store Admin [Shipping Page](https://admin.foxycart.com/admin.php?ThisAction=ShippingSetup), using the code on the `custom-shipping-code.js` file, filling in the data needed on the code from the OAuth client and Lune. There are other configuration variables that need data for the shipping code to work correctly, like the originAddress. +- Deploy this repository to your Netlify account. +- Set the environment variables +- Deploy the site + +Notes: + +- You will also need to configure your Lune account default project bundles, and billing info. +- Note that this integration doesn't handle multi ship transactions. It will only calculate shipping rates with CO2 offset for one shipping, and it will grab the information from the first shipment to create a CO2 offset order in lune. + +### Deploy this repository to Netlify + +1. Click the **Fork** button at the top right corner of this page +2. Log in your Netlify account +3. Add a new site and select the **Import an existing project** option +4. Connect your GitHub account and choose your repository (the repository name should be something like `your-github-username/foxy-node-netlify-functions`) +5. In the site settings, click the **Show advanced** button +6. Under the Advanced build settings section, click the **New variable** button, you will add two environment variables. +7. In the Key field, enter `LUNE_API_KEY` +8. In the Value field, enter your Lune API key +9. Deploy the site + +### Create a new Foxy Webhook + +After the deploy is complete, click the "functions" tab, look for the `lune-integration` function and copy the **Endpoint URL**. + +Configure your webhook using your endpoint URL. + +Specify a query string value within the `API filter query string` with the following parameters: + +``` +zoom=applied_taxes,billing_addresses,custom_fields,customer,discounts,items,items:item_category,items:item_options,payments,shipments,attributes +``` + +Then click on `Update Webhooks Next` + +## Upgrade your webhook + +When new upgrades to this webhook are published, you can use the GitHub Action available in the "Actions" tab in your repository to upgrade your Webhook. + +- Click the "Actions" tab. Agree to use GitHub Actions. +- Click the SyncFork workflow and then "run workflow" + +This will upgrade your repository. + +If you've made customizations, there may be conflicts. In this case you can pull the changes and resolve the conflicts manually. diff --git a/src/functions/lune-integration/custom-shipping-code.js b/src/functions/lune-integration/custom-shipping-code.js new file mode 100644 index 0000000..d4542d3 --- /dev/null +++ b/src/functions/lune-integration/custom-shipping-code.js @@ -0,0 +1,532 @@ +/* --- Config --- */ +// API Key from Lune goes here +const LUNE_API_KEY = ""; + +// OAuth Foxy Integration info goes here +const foxyApi = new FoxySDK.Backend.API({ + clientId: "", //your client id + clientSecret: "", //your client secret + refreshToken: "", // your refresh token +}); +// Weight units for the store ('lb' or 'kg') +const weightUnit = "lb"; + +// Shipping Origin Address and city for you store separated by a coma +const originAddress = "address goes here, city goes here"; + +// How to add the offset amount to the rates? +// If set to false: Add rates with the CO2 offset estimate amount included +// If set to true: Keep regular rates, and duplicate them to add them with the CO2 offset estimate amount included +const addOffsetRatesSeparately = true; + +/* --- End Config --- */ + +const CALCULATE_EMISSIONS_PATH = "https://api.lune.co/v1/estimates/shipping"; +const shipment = cart._embedded["fx:shipment"]; +const shipmentResults = cart._embedded["fx:shipping_results"]; +const addressArray = originAddress.split(","); + +const addressFrom = { + city: addressArray[1], + country: countryCodeToISO3(shipment.origin_country), + state: shipment.origin_region, + street: addressArray[0], + zip: shipment.origin_postal_code, +}; + +const totalWeight = getTotalWeightKg(shipment.total_weight); + +const addressTo = { + city: shipment.city, + country: countryCodeToISO3(shipment.origin_country), + state: shipment.region, + street1: shipment.address1, + zip: shipment.postal_code, +}; + +try { + // Build shipment estimate request objects + const shipmentInfo = shipmentResults.map((shippingResult) => { + const { price, method, service_name, service_id } = shippingResult; + const transportationMethod = getTransportationMethod(service_name); + + return { + estimate_object: transportationMethod + ? buildEstimateObject( + addressFrom, + addressTo, + totalWeight, + transportationMethod + ) + : null, + method, + price, + service_id, + service_name, + }; + }); + console.log("shipmentInfo", shipmentInfo); + + //Calculate Emissions for Each Shipping Rate + const shippingResultsEmissions = []; + const attributesRequestPayload = []; + + for (const shipmentResult of shipmentInfo) { + const { + service_id, + estimate_object, + service_name, + price, + method, + } = shipmentResult; + let estimate = {}; + + // If not Free Shipping + if (estimate_object) { + const response = await fetch(CALCULATE_EMISSIONS_PATH, { + body: JSON.stringify(estimate_object), + headers: { + Authorization: `Bearer ${LUNE_API_KEY}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + console.log("Calculate Emissions Fetch Response Object", response); + if (!response.ok) { + throw new Error(response.statusText); + } + estimate = await response.json(); + + console.log(`Estimate for service ID ${service_id}`, estimate); + } + + const shippingEmissions = { + estimate_object_result: estimate_object ? estimate : null, + method, + price, + service_id, + service_name, + }; + + shippingResultsEmissions.push(shippingEmissions); + } + + // Add rates Separately or Update rates with CO2 Offset amount + upsertCO2ShippingRates(shippingResultsEmissions, attributesRequestPayload); + + //Save estimate ids with service IDs in the cart's attributes + const cartAttributesURL = cart._links["fx:attributes"].href; + const res = await foxyApi.fetch(cartAttributesURL, { + body: JSON.stringify(attributesRequestPayload), + method: "PATCH", + }); + const data = await res.json(); + console.log("Cart attributes: ", data); + console.log("shippingResultsEmissions", shippingResultsEmissions); +} catch (error) { + console.log(error); + rates.error(`${error}: ${error.stack}`); +} + +/* --- Functions --- */ +function upsertCO2ShippingRates( + shippingResultsEmissions, + attributesRequestPayload +) { + shippingResultsEmissions.forEach((result, index) => { + const { + service_id, + service_name, + price, + method, + estimate_object_result, + } = result; + + if (estimate_object_result) { + const subscript = "₂"; + const { id, quote } = estimate_object_result; + const CO2Kg = tonneToKg(quote.estimated_quantity); + const CO2Gr = tonneToGram(quote.estimated_quantity); + const CO2MassString = + CO2Kg < 1 + ? `${CO2Gr} gCO${subscript} Compensated` + : `${CO2Kg} kgCO${subscript} Compensated`; + const compensatedCO2Price = ( + Number(price) + Number(quote.estimated_total_cost) + ).toFixed(2); + + if (addOffsetRatesSeparately) { + const serviceIdNewRate = 10001 + index; + + const serviceNameNewRate = `${service_name} (${CO2MassString})`; + + rates.add( + serviceIdNewRate, + compensatedCO2Price, + method, + serviceNameNewRate + ); + // Add rate Ids and estimate ids as attributes to the cart + const payload = { name: `rate_id_${serviceIdNewRate}`, value: id }; + attributesRequestPayload.push(payload); + } else { + const payload = { name: `rate_id_${service_id}`, value: id }; + const serviceName = `${service_name} (${CO2MassString})`; + rates + .filter(service_id) + .price(compensatedCO2Price) + .service(serviceName); + + attributesRequestPayload.push(payload); + } + } + }); +} + +function buildEstimateObject( + addressFrom, + addressTo, + totalWeight, + transportationMethod +) { + return { + shipment: { + mass: { + amount: totalWeight, + unit: "kg", + }, + }, + route: { + source: { + street_line1: addressFrom.street, + postcode: addressFrom.zip, + city: addressFrom.city, + country_code: addressFrom.country, + }, + destination: { + street_line1: addressTo.street1, + postcode: addressTo.zip, + city: addressTo.city, + country_code: addressTo.country, + }, + }, + method: transportationMethod, + }; +} + +function getTransportationMethod(service_name) { + const serviceName = service_name.trim().toLowerCase(); + + //Setting service name keywords for checking if Air or Ground shipping / Could also be by ship or train + const SERVICE_NAMES = { + AIR: [ + "priority mail", + "priority mail express", + "first-class mail", + "air", + "freight", + "express", + ], + GROUND: ["parcel select", "media mail", "ground", "home"], + FREE: ["free"], + }; + + const VESSEL_TYPE = { + PLANE: "plane", + TRUCK: "truck_generic_van", + }; + + const isAirMethod = SERVICE_NAMES.AIR.some((keyTerm) => + serviceName.includes(keyTerm) + ); + const isGroundMethod = SERVICE_NAMES.GROUND.some((keyTerm) => + serviceName.includes(keyTerm) + ); + const isFreeShipping = SERVICE_NAMES.FREE.some((keyTerm) => + serviceName.includes(keyTerm) + ); + + if (isFreeShipping) return null; + + if (isGroundMethod && !isAirMethod) return VESSEL_TYPE.TRUCK; + + // If not ground, or if not sure default to plane + return VESSEL_TYPE.PLANE; +} + +function convertPoundsToKilograms(pounds) { + const onePound = 0.453592; + return (Number(pounds) * onePound).toFixed(3); +} + +function tonneToKg(tonne) { + const oneTonneInKg = 1000; // 1000 kg per tonne + return (Number(tonne) * oneTonneInKg).toFixed(3); +} + +function tonneToGram(tonne) { + const oneTonneInGrams = 1000000; // 1000000 grams per tonne + return Number(tonne) * oneTonneInGrams; +} + +function getTotalWeightKg(totalWeight) { + if (weightUnit === "lb") { + return convertPoundsToKilograms(totalWeight).toString(); + } + return totalWeight; +} + +function countryCodeToISO3(country) { + const iso3166_3 = { + AF: "AFG", + AX: "ALA", + AL: "ALB", + DZ: "DZA", + AS: "ASM", + AD: "AND", + AO: "AGO", + AI: "AIA", + AQ: "ATA", + AG: "ATG", + AR: "ARG", + AM: "ARM", + AW: "ABW", + AU: "AUS", + AT: "AUT", + AZ: "AZE", + BS: "BHS", + BH: "BHR", + BD: "BGD", + BB: "BRB", + BY: "BLR", + BE: "BEL", + BZ: "BLZ", + BJ: "BEN", + BM: "BMU", + BT: "BTN", + BO: "BOL", + BA: "BIH", + BW: "BWA", + BV: "BVT", + BR: "BRA", + VG: "VGB", + IO: "IOT", + BN: "BRN", + BG: "BGR", + BF: "BFA", + BI: "BDI", + KH: "KHM", + CM: "CMR", + CA: "CAN", + CV: "CPV", + KY: "CYM", + CF: "CAF", + TD: "TCD", + CL: "CHL", + CN: "CHN", + HK: "HKG", + MO: "MAC", + CX: "CXR", + CC: "CCK", + CO: "COL", + KM: "COM", + CG: "COG", + CD: "COD", + CK: "COK", + CR: "CRI", + CI: "CIV", + HR: "HRV", + CU: "CUB", + CY: "CYP", + CZ: "CZE", + DK: "DNK", + DJ: "DJI", + DM: "DMA", + DO: "DOM", + EC: "ECU", + EG: "EGY", + SV: "SLV", + GQ: "GNQ", + ER: "ERI", + EE: "EST", + ET: "ETH", + FK: "FLK", + FO: "FRO", + FJ: "FJI", + FI: "FIN", + FR: "FRA", + GF: "GUF", + PF: "PYF", + TF: "ATF", + GA: "GAB", + GM: "GMB", + GE: "GEO", + DE: "DEU", + GH: "GHA", + GI: "GIB", + GR: "GRC", + GL: "GRL", + GD: "GRD", + GP: "GLP", + GU: "GUM", + GT: "GTM", + GG: "GGY", + GN: "GIN", + GW: "GNB", + GY: "GUY", + HT: "HTI", + HM: "HMD", + VA: "VAT", + HN: "HND", + HU: "HUN", + IS: "ISL", + IN: "IND", + ID: "IDN", + IR: "IRN", + IQ: "IRQ", + IE: "IRL", + IM: "IMN", + IL: "ISR", + IT: "ITA", + JM: "JAM", + JP: "JPN", + JE: "JEY", + JO: "JOR", + KZ: "KAZ", + KE: "KEN", + KI: "KIR", + KP: "PRK", + KR: "KOR", + KW: "KWT", + KG: "KGZ", + LA: "LAO", + LV: "LVA", + LB: "LBN", + LS: "LSO", + LR: "LBR", + LY: "LBY", + LI: "LIE", + LT: "LTU", + LU: "LUX", + MK: "MKD", + MG: "MDG", + MW: "MWI", + MY: "MYS", + MV: "MDV", + ML: "MLI", + MT: "MLT", + MH: "MHL", + MQ: "MTQ", + MR: "MRT", + MU: "MUS", + YT: "MYT", + MX: "MEX", + FM: "FSM", + MD: "MDA", + MC: "MCO", + MN: "MNG", + ME: "MNE", + MS: "MSR", + MA: "MAR", + MZ: "MOZ", + MM: "MMR", + NA: "NAM", + NR: "NRU", + NP: "NPL", + NL: "NLD", + AN: "ANT", + NC: "NCL", + NZ: "NZL", + NI: "NIC", + NE: "NER", + NG: "NGA", + NU: "NIU", + NF: "NFK", + MP: "MNP", + NO: "NOR", + OM: "OMN", + PK: "PAK", + PW: "PLW", + PS: "PSE", + PA: "PAN", + PG: "PNG", + PY: "PRY", + PE: "PER", + PH: "PHL", + PN: "PCN", + PL: "POL", + PT: "PRT", + PR: "PRI", + QA: "QAT", + RE: "REU", + RO: "ROU", + RU: "RUS", + RW: "RWA", + BL: "BLM", + SH: "SHN", + KN: "KNA", + LC: "LCA", + MF: "MAF", + PM: "SPM", + VC: "VCT", + WS: "WSM", + SM: "SMR", + ST: "STP", + SA: "SAU", + SN: "SEN", + RS: "SRB", + SC: "SYC", + SL: "SLE", + SG: "SGP", + SK: "SVK", + SI: "SVN", + SB: "SLB", + SO: "SOM", + ZA: "ZAF", + GS: "SGS", + SS: "SSD", + ES: "ESP", + LK: "LKA", + SD: "SDN", + SR: "SUR", + SJ: "SJM", + SZ: "SWZ", + SE: "SWE", + CH: "CHE", + SY: "SYR", + TW: "TWN", + TJ: "TJK", + TZ: "TZA", + TH: "THA", + TL: "TLS", + TG: "TGO", + TK: "TKL", + TO: "TON", + TT: "TTO", + TN: "TUN", + TR: "TUR", + TM: "TKM", + TC: "TCA", + TV: "TUV", + UG: "UGA", + UA: "UKR", + AE: "ARE", + GB: "GBR", + US: "USA", + UM: "UMI", + UY: "URY", + UZ: "UZB", + VU: "VUT", + VE: "VEN", + VN: "VNM", + VI: "VIR", + WF: "WLF", + EH: "ESH", + YE: "YEM", + ZM: "ZMB", + ZW: "ZWE", + XK: "XKX", + }; + + return iso3166_3[country]; +} diff --git a/src/functions/lune-integration/index.js b/src/functions/lune-integration/index.js new file mode 100644 index 0000000..7d202b8 --- /dev/null +++ b/src/functions/lune-integration/index.js @@ -0,0 +1,85 @@ +const fetch = require("node-fetch"); +const { config } = require("../../../config.js"); + +/** + * Receives the request, gets the lune estimate ID for the selected shipping rate, and creates and order on lune. + * + * @param {Object} requestEvent the request event built by Netlify Functions + * @returns {Promise<{statusCode: number, body: string}>} the response object + */ +async function handler(requestEvent) { + try { + const cartDetails = JSON.parse(requestEvent.body); + const ratePrefix = "rate_id_"; + const selectedShippingRateID = + cartDetails["_embedded"]["fx:shipments"][0].shipping_service_id; + const luneEstimateID = cartDetails["_embedded"]["fx:attributes"].find( + (attr) => attr.name === `${ratePrefix}${selectedShippingRateID}` + ).value; + + const LUNE_API_KEY = config.lune.apiKey; + + if (!luneEstimateID) { + console.log("No lune CO2 estimate ID is provided"); + return { + body: JSON.stringify({ + details: "No lune CO2 estimate ID is provided", + ok: true, + }), + statusCode: 200, + }; + } + + const orderByEstimateIdUrl = "https://api.lune.co/v1/orders/by-estimate"; + const response = await fetch(orderByEstimateIdUrl, { + body: JSON.stringify({ + estimate_id: luneEstimateID, + metadata: { + customer_email: cartDetails.customer_email, + transaction_id: String(cartDetails.id), + }, + }), + headers: { + Authorization: `Bearer ${LUNE_API_KEY}`, + "Content-Type": "application/json", + }, + method: "POST", + }); + const data = await response.json(); + + if (data.id) { + console.log("Order created successfully", data); + return { + body: JSON.stringify({ + details: `Order ${data.id} created successfully on lune`, + ok: true, + }), + statusCode: 200, + }; + } else { + console.error("Error:", data); + return { + body: JSON.stringify({ + details: + "An internal error has occurred when creating a lune order based on the CO2 estimate ID", + ok: false, + }), + statusCode: 200, + }; + } + } catch (error) { + console.error(error); + return { + body: JSON.stringify({ + details: + "An internal error has occurred when creating a lune order based on the CO2 estimate ID", + ok: false, + }), + statusCode: 500, + }; + } +} + +module.exports = { + handler, +};