diff --git a/README.md b/README.md index 695e25a..edf13ba 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,11 @@ PPC.Subscriptions.createRequest({ ```txt PayPal.init(client_id, client_secret, 'sandbox|live', defaults); PayPal.request({ method, url, headers, json }); +``` +### Subscrptions (Recurring Payments) + +```txt // Webhook 'event_type': PayPal.Product.create({ ... }); // CATALOG.PRODUCT.CREATED @@ -66,6 +70,21 @@ PayPal.Subscription.details(id); PayPal.Subscription.cancel(id, { reason }); ``` +### Orders (One-Time Payments) + +```txt + // Webhook 'event_type': + +PayPal.Order.createRequest({ purchase_units }); // CHECKOUT.ORDER.APPROVED +PayPal.Order.capture(id, { final_capture }); // PAYMENT.CAPTURE.COMPLETED +``` + +See also: + +- +- +- + # Redirects - `return_url` @@ -73,22 +92,41 @@ PayPal.Subscription.cancel(id, { reason }); #### `return_url` -Subscription Request `return_url` will include the following: +**_Order_** and **_Subscription_** requests have a return `return_url` will be +called with some or all of the following params: ```txt +# Order +https://example.com/redirects/paypal-checkout/return + ?token=XXXXXXXXXXXXXXXXX + &PayerID=XXXXXXXXXXXXX +``` + +- `token` is the **_Order ID_** +- `PayerID` is... exactly what it seems (no idea how you can access the Payer + object though) + +```txt +# Subscrption https://example.com/redirects/paypal-checkout/return ?subscription_id=XXXXXXXXXXXXXX &ba_token=BA-00000000000000000 &token=XXXXXXXXXXXXXXXXX ``` +- `subscription_id` refers to both the **_Subscription ID_** and the + `billing_agreement_id` of the corresponding **_Payments_**. +- `ba_token` (deprecated) refers to `/v1/payments/billing-agreements/:ba_token` +- `token` refers to the **_Order ID_** (perhaps created as part of the setup fee + or first billing cycle payment). + #### `cancel_url` The `cancel_url` will have the same query params as the `return_url`. -Also, PayPal presents the raw `cancel_url` and will NOT update the subscription -status. It's up to you to confirm with the user and change the status to -`CANCELLED`. +Also, PayPal presents the raw `cancel_url` and will NOT update the order or +subscription status. It's up to you to confirm with the user and change the +status to `CANCELLED`. # Webhooks @@ -117,6 +155,10 @@ See: # Notes +My discussions with Twitter Support (@paypaldev): + +- + Note: Just about everything in the PayPal SDK that uses `ALL_CAPS` is a `constant`/`enum` representing an option you can pick from limited number of options. @@ -124,7 +166,7 @@ options. Sandbox accounts (for creating fake purchases) can be managed at: -Note on Auth + Capture: +## Auth vs Capture > Authorization and capture enables you to authorize fund availability but delay > fund capture. This can be useful for merchants who have a delayed order @@ -141,9 +183,170 @@ Note on Auth + Capture: > - > - -Buttons: +You can auth once and capture multiple times (unless you set `final_capture`). + +## PayPal Checkout Buttons - <== THE ONE YOU WANT - Check out with PayPal - - + +# Glossary + +## Webhook Event: CHECKOUT.ORDER.APPROVED + +```json +{ + "id": "WH-1V203642KU442722T-3S346483MF8733038", + "event_version": "1.0", + "create_time": "2021-10-17T05:04:22.404Z", + "resource_type": "checkout-order", + "resource_version": "2.0", + "event_type": "CHECKOUT.ORDER.APPROVED", + "summary": "An order has been approved by buyer", + "resource": { + "create_time": "2021-10-17T05:03:26Z", + "purchase_units": [ + { + "reference_id": "{purchase-unit-id}", + "amount": { + "currency_code": "USD", + "value": "10.00" + }, + "payee": { + "email_address": "sb-a9xvi8075587@business.example.com", + "merchant_id": "4RXRQC77UD53U", + "display_data": { + "brand_name": "Bliss via The Root Group, LLC" + } + }, + "description": "1 year of pure Bliss", + "custom_id": "{my-local-db-purchase-id}", + "soft_descriptor": "Bliss" + } + ], + "links": [ + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/4K5112848U951142F", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/4K5112848U951142F", + "rel": "update", + "method": "PATCH" + }, + { + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/4K5112848U951142F/capture", + "rel": "capture", + "method": "POST" + } + ], + "id": "4K5112848U951142F", + "intent": "CAPTURE", + "payer": { + "name": { + "given_name": "John", + "surname": "Doe" + }, + "email_address": "sb-ka5d18075586@personal.example.com", + "payer_id": "YTENGYR8PAF9A", + "address": { + "country_code": "US" + } + }, + "status": "APPROVED" + }, + "links": [ + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-1V203642KU442722T-3S346483MF8733038", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-1V203642KU442722T-3S346483MF8733038/resend", + "rel": "resend", + "method": "POST" + } + ] +} +``` + +## Webhook Event: PAYMENT.CAPTURE.COMPLETED + +```json +{ + "id": "WH-3UT90572MR669760L-7LL94124G5389840D", + "event_version": "1.0", + "create_time": "2021-10-17T05:05:03.389Z", + "resource_type": "capture", + "resource_version": "2.0", + "event_type": "PAYMENT.CAPTURE.COMPLETED", + "summary": "Payment completed for $ 10.0 USD", + "resource": { + "amount": { + "value": "10.00", + "currency_code": "USD" + }, + "seller_protection": { + "dispute_categories": ["ITEM_NOT_RECEIVED", "UNAUTHORIZED_TRANSACTION"], + "status": "ELIGIBLE" + }, + "supplementary_data": { + "related_ids": { + "order_id": "4K5112848U951142F" + } + }, + "update_time": "2021-10-17T05:04:29Z", + "create_time": "2021-10-17T05:04:29Z", + "final_capture": true, + "seller_receivable_breakdown": { + "paypal_fee": { + "value": "0.84", + "currency_code": "USD" + }, + "gross_amount": { + "value": "10.00", + "currency_code": "USD" + }, + "net_amount": { + "value": "9.16", + "currency_code": "USD" + } + }, + "custom_id": "{my-local-db-purchase-id}", + "links": [ + { + "method": "GET", + "rel": "self", + "href": "https://api.sandbox.paypal.com/v2/payments/captures/5VK462069F664902F" + }, + { + "method": "POST", + "rel": "refund", + "href": "https://api.sandbox.paypal.com/v2/payments/captures/5VK462069F664902F/refund" + }, + { + "method": "GET", + "rel": "up", + "href": "https://api.sandbox.paypal.com/v2/checkout/orders/4K5112848U951142F" + } + ], + "id": "5VK462069F664902F", + "status": "COMPLETED" + }, + "links": [ + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-3UT90572MR669760L-7LL94124G5389840D", + "rel": "self", + "method": "GET" + }, + { + "href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-3UT90572MR669760L-7LL94124G5389840D/resend", + "rel": "resend", + "method": "POST" + } + ] +} +``` diff --git a/paypal-checkout.js b/paypal-checkout.js index 6d626ce..aba8fa7 100644 --- a/paypal-checkout.js +++ b/paypal-checkout.js @@ -114,6 +114,69 @@ function enumify(obj) { } */ +let Order = {}; +Order.intents = { + CAPTURE: "CAPTURE", + AUTHORIZE: "AUTHORIZE", +}; +// 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 @@ -447,6 +510,7 @@ Subscription.cancel = async function _showProductDetails(id, { reason }) { 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; diff --git a/tests/crud.js b/tests/crud.js index a869280..3982d4a 100644 --- a/tests/crud.js +++ b/tests/crud.js @@ -11,7 +11,7 @@ if (!process.env.PAYPAL_CLIENT_ID) { } let PayPal = require("../"); -let { Plan, Product, Subscription } = PayPal; +let { Plan, Product /*, Subscription*/ } = PayPal; async function test() { let products = await Product.list(); @@ -48,7 +48,11 @@ async function test() { } if (require.main === module) { - PayPal.init(process.env.PAYPAL_CLIENT_ID, process.env.PAYPAL_CLIENT_SECRET); + PayPal.init( + process.env.PAYPAL_CLIENT_ID, + process.env.PAYPAL_CLIENT_SECRET, + "sandbox" + ); test().catch(function (err) { console.error("Something bad happened:"); console.error(JSON.stringify(err, null, 2)); diff --git a/tests/order.js b/tests/order.js new file mode 100644 index 0000000..13595d1 --- /dev/null +++ b/tests/order.js @@ -0,0 +1,88 @@ +"use strict"; + +require("dotenv").config({ path: ".env" }); +require("dotenv").config({ path: ".env.secret" }); + +if (!process.env.PAYPAL_CLIENT_ID) { + console.error( + "Please copy example.env to .env and update the values from the PayPal API Dashboard at https://developer.paypal.com/developer/applications" + ); + process.exit(1); +} + +let PayPal = require("../"); +let { Order } = PayPal; + +async function test() { + let ppcOrder = await Order.createRequest({ + application_context: { + brand_name: "Bliss via The Root Group, LLC", + shipping_preference: "NO_SHIPPING", + // ("checkout with paypal") or "BILLING" (credit card) or NO_PREFERENCE + landing_page: "LOGIN", + user_action: "PAY_NOW", + return_url: `https://example.com/api/redirects/paypal-checkout/return`, + cancel_url: `https://example.com/api/redirects/paypal-checkout/cancel`, + }, + purchase_units: [ + { + request_id: 0, + custom_id: "xxxx", // Our own (User x Product) ID + description: "1 year of pure Bliss", // shown in PayPal Checkout Flow UI + soft_descriptor: "Bliss", // on the charge (credit card) statement + amount: { + currency_code: "USD", + value: "10.00", + }, + }, + ], + }); + + console.info(); + console.info("Order:"); + console.info(JSON.stringify(ppcOrder, null, 2)); + + // wait for user to click URL and accept + await new Promise(function (resolve) { + console.info(); + console.info("Please approve the order at the following URL:"); + console.info(); + console.info( + "Approve URL:", + ppcOrder.links.find(function (link) { + return "approve" === link.rel; + }).href + ); + console.info("Username:", process.env.PAYPAL_SANDBOX_EMAIL); + console.info("Password:", process.env.PAYPAL_SANDBOX_PASSWORD); + console.info(); + console.info("Did you approve it? Hit the key to continue..."); + console.info(); + process.stdin.once("data", resolve); + }); + process.stdin.pause(); + + let ppcCapture = await Order.capture(ppcOrder.id, { + note_to_payer: undefined, + final_order: true, + }); + + console.info(); + console.info("Capture:"); + console.info(JSON.stringify(ppcCapture, null, 2)); + + console.info(); +} + +if (require.main === module) { + PayPal.init( + process.env.PAYPAL_CLIENT_ID, + process.env.PAYPAL_CLIENT_SECRET, + "sandbox" + ); + test().catch(function (err) { + console.error("Something bad happened:"); + console.error(err); + console.error(JSON.stringify(err, null, 2)); + }); +}