"use strict"; let qs = require("querystring"); let request = require("@root/request"); let PayPal = {}; PayPal.init = function (client_id, client_secret, env, opts) { if (!opts) { opts = {}; } if (!("total_required" in opts)) { opts.total_required = true; } if (!opts.page_size) { opts.page_size = 20; } if (!opts.prefer) { opts.prefer = "return=representation"; } PayPal.__sandboxApiBaseUrl = "https://api-m.sandbox.paypal.com"; PayPal.__liveApiBaseUrl = "https://api.paypal.com"; if ("live" === env) { PayPal.__baseUrl = PayPal.__liveApiBaseUrl; } else { PayPal.__baseUrl = PayPal.__sandboxApiBaseUrl; console.debug("[PayPal Checkout] ENVIRONMENT=sandbox"); } PayPal.__id = client_id; PayPal.__secret = client_secret; PayPal.__defaultQuery = { page_size: opts.page_size, total_required: opts.total_required, page: 1, }; PayPal.__prefer = opts.prefer; }; PayPal.request = async function _paypalRequest(reqObj) { let headers = {}; if (reqObj.request_id) { // Optional and if passed, helps identify idempotent requests headers["PayPal-Request-Id"] = reqObj.request_id; } // ex: https://api-m.sandbox.paypal.com/v1/billing/subscriptions reqObj.url = `${PayPal.__baseUrl}${reqObj.url}`; reqObj.headers = Object.assign(headers, reqObj.headers || {}); reqObj.auth = { user: PayPal.__id, pass: PayPal.__secret, }; return await request(reqObj).then(sanitize); }; PayPal._patch = function (obj) { let ops = []; Object.keys(obj).forEach(function (k) { let val = obj[k]; if ("undefined" === typeof val) { return; } let op = "replace"; if (null === val) { op = "delete"; val = undefined; } ops.push({ path: `/${k}`, op: op, value: val, }); }); return ops; }; function justBody(resp) { return resp.body; } function sanitize(resp) { resp = resp.toJSON(); Object.keys(resp.headers).forEach(function (k) { if (k.toLowerCase().match(/Auth|Cookie|Token|Key/i)) { resp.headers[k] = "[redacted]"; } }); Object.keys(resp.request.headers).forEach(function (k) { if (k.toLowerCase().match(/Auth|Cookie|Token|Key/i)) { resp.request.headers[k] = "[redacted]"; } }); return resp; } function must201or200(resp) { if (![200, 201].includes(resp.statusCode)) { let err = new Error("[@root/paypal-checkout] bad response"); err.response = resp; throw err; } return resp; } function must204or200(resp) { if (![200, 204].includes(resp.statusCode)) { let err = new Error("[@root/paypal-checkout] bad response"); err.response = resp; throw err; } return resp; } /* function enumify(obj) { Object.keys(obj).forEach(function (k) { obj[k] = k; }); } */ let shipping_preferences = { GET_FROM_FILE: "GET_FROM_FILE", // provided, or selectable from PayPal addresses SET_PROVIDED_ADDRESS: "SET_PROVIDED_ADDRESS", // user can't change it here NO_SHIPPING: "NO_SHIPPING", // duh }; let Order = {}; Order.intents = { CAPTURE: "CAPTURE", AUTHORIZE: "AUTHORIZE", }; Order.user_actions = { CONTINUE: "CONTINUE", PAY_NOW: "PAY_NOW", }; Order.shipping_preferences = shipping_preferences; // See // https://developer.paypal.com/docs/api/orders/v2/#orders_create // https://developer.paypal.com/docs/api/orders/v2/#definition-purchase_unit_request // https://developer.paypal.com/docs/api/orders/v2/#definition-order_application_context Order.createRequest = async function (order) { if (!order.intent) { order.intent = Order.intents.CAPTURE; } if (!order.purchase_units?.length) { throw new Error("must have 'purchase_units'"); } /* { intent: "CAPTURE", purchase_units: [ { amount: { currency_code: "USD", value: "100.00", }, }, ], } */ return await PayPal.request({ method: "POST", url: `/v2/checkout/orders`, json: order, }) .then(must201or200) .then(justBody); }; Order.details = async function (id) { return await PayPal.request({ url: `/v2/checkout/orders/${id}`, json: true, }) .then(must201or200) // 200 .then(justBody); }; /** * Captures (finalizes) an approved order. * @param {String} id * @param {any} body */ Order.capture = async function (id, { note_to_payer, final_capture }) { return await PayPal.request({ method: "POST", url: `/v2/checkout/orders/${id}/capture`, json: { note_to_payer, final_capture }, }) .then(must201or200) .then(justBody); }; let Product = {}; // SaaS would be type=SERVICE, category=SOFTWARE Product.types = { DIGITAL: "DIGITAL", PHYSICAL: "PHYSICAL", SERVICE: "SERVICE", }; Product.__typeNames = Object.keys(Product.types); // Documented under "categories" at // https://developer.paypal.com/docs/api/catalog-products/v1/ // Optionally load the full list // (for those that want the type linting) try { // the full list Product.categories = require("@root/paypal-checkout-product-categories"); } catch { // the short list Product.categories = { SOFTWARE: "SOFTWARE", PHYSICAL_GOOD: "PHYSICAL_GOOD", DIGITAL_MEDIA_BOOKS_MOVIES_MUSIC: "DIGITAL_MEDIA_BOOKS_MOVIES_MUSIC", DIGITAL_GAMES: "DIGITAL_GAMES", }; } Product.__categoryNames = Object.keys(Product.categories); Product.create = async function _createProduct({ request_id, name, description, type, category, image_url, home_url, }) { if (request_id) { if (!request_id.startsWith("PROD-")) { console.warn(`Warn: product ID should start with "PROD-"`); } } if (!Product.__typeNames.includes(type)) { console.warn(`Warn: unknown product type '${type}'`); } if (!Product.__categoryNames.includes(category)) { console.warn(`Warn: unknown product category '${category}'`); } return await PayPal.request({ method: "POST", url: "/v1/catalogs/products", request_id: request_id, json: { // ex: "Video Streaming Service" name: name, // ex: "Video streaming service" description: description, // ex: "SERVICE", "PHYSICAL", "DIGITAL" type: type, // ex: "SOFTWARE", "PHYSICAL_GOOD" category: category, // ex: "https://example.com/streaming.jpg" image_url: image_url, // ex: "https://example.com/home" home_url: home_url, }, }) .then(must201or200) .then(justBody); }; Product.list = async function _listProducts(query = {}) { query = Object.assign({}, PayPal.__defaultQuery, query); let search = qs.stringify(query); return await PayPal.request({ url: `/v1/catalogs/products?${search}`, json: true, }) .then(must201or200) .then(justBody); }; Product.details = async function _showProductDetails(id) { return await PayPal.request({ url: `/v1/catalogs/products/${id}`, json: true, }) .then(must201or200) .then(justBody); }; /** * Update product info * @param id * @param {{ * description: string, * category: string, * image_url: string, * home_url: string, * }} */ Product.update = async function _updateProduct( id, { description, category, image_url, home_url } ) { let body = PayPal._patch({ description, category, image_url, home_url }); return await PayPal.request({ method: "PATCH", url: `/v1/catalogs/products/${id}`, json: body, }).then(must204or200); }; let Plan = {}; Plan.intervals = { DAY: "DAY", WEEK: "WEEK", MONTH: "MONTH", YEAR: "YEAR", }; Plan.tenures = { TRIAL: "TRIAL", REGULAR: "REGULAR", }; // See https://developer.paypal.com/docs/api/subscriptions/v1/ Plan.create = async function _createPlan({ request_id, status = "ACTIVE", product_id, name, description = "", billing_cycles, payment_preferences, taxes, // optional quantity_supported = false, }) { if (request_id) { if (!request_id.startsWith("PLAN-")) { // ex: PLAN-18062020-001 console.warn(`Warn: plan ID should start with "PLAN-"`); } } return await PayPal.request({ method: "POST", url: "/v1/billing/plans", request_id: request_id, // TODO should we make this the default? headers: { Prefer: "return=representation", }, json: { // ex: "PROD-6XB24663H4094933M" product_id: product_id, // ex: "Basic Plan" name: name, // ex: "Basic plan" description: description, // ex: "CREATED", "ACTIVE", "INACTIVE" status: status, // ex: TODO billing_cycles: billing_cycles.map(function (cycle, i) { // sequence is the index in the array, // which should never be out-of-order if (!cycle.frequency.interval_count) { cycle.frequency.interval_count = 1; } cycle.sequence = i + 1; if (!cycle.tenure_type) { cycle.tenure_type = Plan.tenures.REGULAR; } if (!cycle.total_cycles) { cycle.total_cycles = 0; } return cycle; }), // TODO ??? payment_preferences: payment_preferences, taxes: taxes, quantity_supported: quantity_supported, }, }) .then(must201or200) .then(justBody); }; Plan.list = async function _listPlans(query = {}) { query = Object.assign({}, PayPal.__defaultQuery, query); let search = qs.stringify(query); return await PayPal.request({ url: `/v1/billing/plans?${search}`, json: true, }) .then(must201or200) .then(justBody); }; Plan.details = async function _showPlanDetails(id) { return await PayPal.request({ url: `/v1/billing/plans/${id}`, json: true, }) .then(must201or200) .then(justBody); }; /** * Update plan info * @param id * @param {{ * description: string, * payment_preferences.auto_bill_outstandin: boolean, * taxes.percentage: string, * payment_preferences.payment_failure_threshold: number, * payment_preferences.setup_fee: string, * payment_preferences.setup_fee_failure_action: string, * }} */ Plan.update = async function _updatePlan( id, // TODO handle nested keys (ex: 'taxes.percentage') { description /*, payment_preferences, taxes*/ } ) { return await PayPal.request({ method: "PATCH", url: `/v1/billing/plans/${id}`, json: PayPal._patch({ description }), }).then(must204or200); }; let Subscription = {}; Subscription.actions = { CONTINUE: "CONTINUE", SUBSCRIBE_NOW: "SUBSCRIBE_NOW", }; Subscription.shipping_preferences = shipping_preferences; Subscription.payer_selections = { PAYPAL: "PAYPAL", }; Subscription.payee_preferences = { UNRESTRICTED: "UNRESTRICTED", IMMEDIATE_PAYMENT_REQUIRED: "IMMEDIATE_PAYMENT_REQUIRED", }; // See https://developer.paypal.com/docs/api/subscriptions/v1/#subscriptions_create Subscription.createRequest = async function _createSubscription({ request_id, plan_id, start_time, quantity, shipping_amount, subscriber, application_context, custom_id, plan, }) { return await PayPal.request({ method: "POST", url: "/v1/billing/subscriptions", request_id: request_id, json: { // ex: "P-5ML4271244454362WXNWU5NQ" plan_id: plan_id, // ex: "2018-11-01T00:00:00Z" (must be in the future) start_time: start_time, // ex: "20" quantity: quantity, // ex: { currency_code: "USD", value: "10.00", }, shipping_amount: shipping_amount, /* ex: { name: { given_name: "John", surname: "Doe" }, email_address: "customer@example.com", shipping_address: { name: { full_name: "John Doe" }, address: { address_line_1: "123 Sesame Street", address_line_2: "Building 17", admin_area_2: "San Jose", admin_area_1: "CA", postal_code: "95131", country_code: "US", }, } } */ subscriber: subscriber, /* ex: { brand_name: "walmart", locale: "en-US", shipping_preference: "SET_PROVIDED_ADDRESS", user_action: "SUBSCRIBE_NOW", payment_method: { payer_selected: "PAYPAL", payee_preferred: "IMMEDIATE_PAYMENT_REQUIRED", }, return_url: "https://example.com/returnUrl", cancel_url: "https://example.com/cancelUrl", } */ application_context: application_context, custom_id: custom_id, plan: plan, }, }) .then(must201or200) .then(justBody); }; Subscription.details = async function _getSubscription(id) { return await PayPal.request({ url: `/v1/billing/subscriptions/${id}`, json: true, }) .then(must201or200) .then(justBody); }; /** * Cancel a subscription (prevent future auto billing) * @param id * @param {{ * reason: string * }} */ Subscription.cancel = async function _showProductDetails(id, { reason }) { return await PayPal.request({ method: "POST", url: `/v1/catalogs/products/${id}/cancel`, json: { reason }, }) .then(must201or200) .then(justBody); }; module.exports.init = PayPal.init; module.exports.request = PayPal.request; module.exports.Order = Order; module.exports.Plan = Plan; module.exports.Product = Product; module.exports.Subscription = Subscription;