From e011a8c33373b24ebe5f3e983fc9441d5ed47a85 Mon Sep 17 00:00:00 2001 From: "Zed A. Shaw" Date: Mon, 12 Jan 2026 11:38:19 -0500 Subject: [PATCH] Initial setup for paypal but can't go further until I get the sandbox setup. --- features/paypal/api.go | 67 ++++++++++++----- features/paypal/api.js | 160 ++++++++++++++++++++++++++++++++++++++++ features/paypal/init.go | 34 --------- static/js/paypal_app.js | 113 ++++++++++++++++++++++++++++ static/style.css | 59 +++++---------- views/paypal/index.html | 22 +++++- 6 files changed, 360 insertions(+), 95 deletions(-) create mode 100644 features/paypal/api.js create mode 100644 static/js/paypal_app.js diff --git a/features/paypal/api.go b/features/paypal/api.go index 17e3274..a55fc30 100644 --- a/features/paypal/api.go +++ b/features/paypal/api.go @@ -7,41 +7,72 @@ import ( "context" . "MY/webapp/common" config "MY/webapp/config" + "fmt" ) -func GetApiStart(c *fiber.Ctx) error { - ctx := context.Background() - pay, err := paypal.NewClient( +func CreatePaypal() (*paypal.Client, error) { + return paypal.NewClient( config.Settings.Paypal.ClientID, config.Settings.Paypal.SecretID, config.Settings.Paypal.URL) // or paypal.APIBaseLive +} + +func PostApiOrder(c *fiber.Ctx) error { + pay, err := CreatePaypal() + if err != nil { return IfErrNil(err, c) } pay.SetLog(os.Stdout) - pay.Token = &paypal.TokenResponse{Token: "dummy"} - - payout := paypal.Payout{ - Items: []paypal.PayoutItem{ - { - Amount: &paypal.AmountPayout{ - Currency: "USD", - Value: "10.00", + units := []paypal.PurchaseUnitRequest{ + { + ReferenceID: "myinternalid1", + Amount: &paypal.PurchaseUnitAmount{ + Currency: "USD", + Value: "10.99", + }, + Description: "Product description", + Items: []paypal.Item{ + { + Name: "Learn Go the Hard Way", + UnitAmount: &paypal.Money{ + Currency: "USD", + Value: "10.99", + }, + Quantity: "1", }, - Receiver: "test@example.com", }, }, - SenderBatchHeader: &paypal.SenderBatchHeader{ - SenderBatchID: "BATCH123", - }, } - response, err := pay.CreatePayout(ctx, payout) + source := &paypal.PaymentSource{} + appCtx := &paypal.ApplicationContext{} + + order, err := pay.CreateOrder(context.TODO(), paypal.OrderIntentCapture, units, source, appCtx) + + fmt.Println("ORDER", order) + + return c.JSON(order) +} + +func PostApiOrderCapture(c *fiber.Ctx) error { + orderID := c.Params("orderID") + + fmt.Println("POST ORDER CAPTURE", orderID) + pay, err := CreatePaypal() + if err != nil { return IfErrNil(err, c) } + + capture, err := pay.CaptureOrder(context.TODO(), orderID, paypal.CaptureOrderRequest{}) if err != nil { return IfErrNil(err, c) } - return c.JSON(response) + fmt.Println("CAPTURE", capture) + + return c.JSON(fiber.Map{"status": "ok"}) } func SetupApi(app *fiber.App) { - app.Get("/api/paypal/start", GetApiStart) + app.Post("/api/paypal/order", PostApiOrder) + app.Post("/api/paypal/order/:orderID/capture", PostApiOrderCapture) } + + diff --git a/features/paypal/api.js b/features/paypal/api.js new file mode 100644 index 0000000..f8c704e --- /dev/null +++ b/features/paypal/api.js @@ -0,0 +1,160 @@ +import express from "express"; +import "dotenv/config"; +import { + ApiError, + CheckoutPaymentIntent, + Client, + Environment, + LogLevel, + OrdersController, + PaymentsController, + PaypalExperienceLandingPage, + PaypalExperienceUserAction, + ShippingPreference, +} from "@paypal/paypal-server-sdk"; +import bodyParser from "body-parser"; + +const app = express(); +app.use(bodyParser.json()); + +const { + PAYPAL_CLIENT_ID, + PAYPAL_CLIENT_SECRET, + PORT = 8080, +} = process.env; + +const client = new Client({ + clientCredentialsAuthCredentials: { + oAuthClientId: PAYPAL_CLIENT_ID, + oAuthClientSecret: PAYPAL_CLIENT_SECRET, + }, + timeout: 0, + environment: Environment.Sandbox, + logging: { + logLevel: LogLevel.Info, + logRequest: { logBody: true }, + logResponse: { logHeaders: true }, + }, +}); + +const ordersController = new OrdersController(client); +const paymentsController = new PaymentsController(client); + + +/** + * Create an order to start the transaction. + * @see https://developer.paypal.com/docs/api/orders/v2/#orders_create + */ +const createOrder = async (cart) => { + const collect = { + body: { + intent: "CAPTURE", + purchaseUnits: [ + { + amount: { + currencyCode: "USD", + value: "100", + breakdown: { + itemTotal: { + currencyCode: "USD", + value: "100", + }, + }, + }, + // lookup item details in `cart` from database + items: [ + { + name: "T-Shirt", + unitAmount: { + currencyCode: "USD", + value: "100", + }, + quantity: "1", + description: "Super Fresh Shirt", + sku: "sku01", + }, + ], + }, + ], + }, + prefer: "return=minimal", + }; + + + try { + const { body, ...httpResponse } = await ordersController.createOrder( + collect + ); + // Get more response info... + // const { statusCode, headers } = httpResponse; + return { + jsonResponse: JSON.parse(body), + httpStatusCode: httpResponse.statusCode, + }; + } catch (error) { + if (error instanceof ApiError) { + // const { statusCode, headers } = error; + throw new Error(error.message); + } + } +}; + +// createOrder route +app.post("/api/orders", async (req, res) => { + try { + // use the cart information passed from the front-end to calculate the order amount detals + const { cart } = req.body; + const { jsonResponse, httpStatusCode } = await createOrder(cart); + res.status(httpStatusCode).json(jsonResponse); + } catch (error) { + console.error("Failed to create order:", error); + res.status(500).json({ error: "Failed to create order." }); + } +}); + + +/** + * Capture payment for the created order to complete the transaction. + * @see https://developer.paypal.com/docs/api/orders/v2/#orders_capture + */ +const captureOrder = async (orderID) => { + const collect = { + id: orderID, + prefer: "return=minimal", + }; + + try { + const { body, ...httpResponse } = await ordersController.captureOrder( + collect + ); + // Get more response info... + // const { statusCode, headers } = httpResponse; + return { + jsonResponse: JSON.parse(body), + httpStatusCode: httpResponse.statusCode, + }; + } catch (error) { + if (error instanceof ApiError) { + // const { statusCode, headers } = error; + throw new Error(error.message); + } + } +}; + +// captureOrder route +app.post("/api/orders/:orderID/capture", async (req, res) => { + try { + const { orderID } = req.params; + const { jsonResponse, httpStatusCode } = await captureOrder(orderID); + res.status(httpStatusCode).json(jsonResponse); + } catch (error) { + console.error("Failed to create order:", error); + res.status(500).json({ error: "Failed to capture order." }); + } +}); + + +app.listen(PORT, () => { + console.log(`Node server listening at http://localhost:${PORT}/`); +}); + diff --git a/features/paypal/init.go b/features/paypal/init.go index b7ae1a5..0958d4a 100644 --- a/features/paypal/init.go +++ b/features/paypal/init.go @@ -2,43 +2,9 @@ package features_paypal import ( "github.com/gofiber/fiber/v2" - - "net/http" - "net/http/httptest" - - mockserver "github.com/plutov/paypal/v4/mockserver" - srvPayouts "github.com/plutov/paypal/v4/mockserver/payments_payouts_batch_v1" - srvShipping "github.com/plutov/paypal/v4/mockserver/shipping_shipment_tracking_v1" - - "MY/webapp/config" ) -var PAYPAL *httptest.Server = nil - -func createTestServer() *httptest.Server { - mock := &mockserver.MockServer{} - mux := http.NewServeMux() - - payoutsSI := srvPayouts.NewStrictHandler(mock, nil) - srvPayouts.HandlerWithOptions(payoutsSI, srvPayouts.StdHTTPServerOptions{ - BaseRouter: mux, - }) - - shippingSI := srvShipping.NewStrictHandler(mock, nil) - srvShipping.HandlerWithOptions(shippingSI, srvShipping.StdHTTPServerOptions{ - BaseRouter: mux, - }) - - return httptest.NewServer(mux) -} - func Setup(app *fiber.App) { - // if you don't set the URL then use a fake mock - if config.Settings.Paypal.URL == "" { - PAYPAL = createTestServer() - config.Settings.Paypal.URL = PAYPAL.URL - } - SetupApi(app) SetupViews(app) } diff --git a/static/js/paypal_app.js b/static/js/paypal_app.js new file mode 100644 index 0000000..bfc40aa --- /dev/null +++ b/static/js/paypal_app.js @@ -0,0 +1,113 @@ +const paypalButtons = window.paypal.Buttons({ + style: { + shape: "rect", + layout: "vertical", + color: "gold", + label: "pay", + }, + message: { + amount: 100, + }, + async createOrder() { + try { + const response = await fetch("/api/paypal/order", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + // use the "body" param to optionally pass additional order information + // like product ids and quantities + // body: JSON.stringify({ + // cart: [ + // { + // id: "YOUR_PRODUCT_ID", + // quantity: "YOUR_PRODUCT_QUANTITY", + // }, + // ], + // }), + }); + + const orderData = await response.json(); + + if (orderData.id) { + return orderData.id; + } + const errorDetail = orderData?.details?.[0]; + const errorMessage = errorDetail + ? `${errorDetail.issue} ${errorDetail.description} (${orderData.debug_id})` + : JSON.stringify(orderData); + + throw new Error(errorMessage); + } catch (error) { + console.error(error); + // resultMessage(`Could not initiate PayPal Checkout...

${error}`); + } + }, + async onApprove(data, actions) { + try { + const response = await fetch( + `/api/paypal/order/${data.orderID}/capture`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + } + ); + + const orderData = await response.json(); + // Three cases to handle: + // (1) Recoverable INSTRUMENT_DECLINED -> call actions.restart() + // (2) Other non-recoverable errors -> Show a failure message + // (3) Successful transaction -> Show confirmation or thank you message + + const errorDetail = orderData?.details?.[0]; + + if (errorDetail?.issue === "INSTRUMENT_DECLINED") { + // (1) Recoverable INSTRUMENT_DECLINED -> call actions.restart() + // recoverable state, per + // https://developer.paypal.com/docs/checkout/standard/customize/handle-funding-failures/ + return actions.restart(); + } else if (errorDetail) { + // (2) Other non-recoverable errors -> Show a failure message + throw new Error( + `${errorDetail.description} (${orderData.debug_id})` + ); + } else if (!orderData.purchase_units) { + throw new Error(JSON.stringify(orderData)); + } else { + // (3) Successful transaction -> Show confirmation or thank you message + // Or go to another URL: actions.redirect('thank_you.html'); + const transaction = + orderData?.purchase_units?.[0]?.payments?.captures?.[0] || + orderData?.purchase_units?.[0]?.payments + ?.authorizations?.[0]; + resultMessage( + `Transaction ${transaction.status}: ${transaction.id}
+
See console for all available details` + ); + console.log( + "Capture result", + orderData, + JSON.stringify(orderData, null, 2) + ); + } + } catch (error) { + console.error(error); + resultMessage( + `Sorry, your transaction could not be processed...

${error}` + ); + } + }, + + +}); +paypalButtons.render("#paypal-button-container"); + + +// Example function to show a result to the user. Your site's UI library can be used instead. +function resultMessage(message) { + const container = document.querySelector("#result-message"); + container.innerHTML = message; +} + diff --git a/static/style.css b/static/style.css index 68642d0..c4a5147 100644 --- a/static/style.css +++ b/static/style.css @@ -243,6 +243,24 @@ .left-40 { left: calc(var(--spacing) * 40); } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } .block { display: block; } @@ -267,9 +285,6 @@ .aspect-\[9\/12\]\! { aspect-ratio: 9/12 !important; } - .aspect-square { - aspect-ratio: 1 / 1; - } .aspect-square\! { aspect-ratio: 1 / 1 !important; } @@ -309,15 +324,9 @@ .min-w-md { min-width: var(--container-md); } - .border-collapse { - border-collapse: collapse; - } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } - .resize { - resize: both; - } .grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -345,9 +354,6 @@ .justify-evenly { justify-content: space-evenly; } - .gap-0 { - gap: calc(var(--spacing) * 0); - } .gap-0\! { gap: calc(var(--spacing) * 0) !important; } @@ -406,10 +412,6 @@ } } } - .border { - border-style: var(--tw-border-style); - border-width: 1px; - } .border-1 { border-style: var(--tw-border-style); border-width: 1px; @@ -474,9 +476,6 @@ .bg-gray-700 { background-color: var(--color-gray-700); } - .bg-green-400 { - background-color: var(--color-green-400); - } .bg-green-400\! { background-color: var(--color-green-400) !important; } @@ -486,18 +485,12 @@ .\!p-4 { padding: calc(var(--spacing) * 4) !important; } - .p-0 { - padding: calc(var(--spacing) * 0); - } .p-0\! { padding: calc(var(--spacing) * 0) !important; } .p-1 { padding: calc(var(--spacing) * 1); } - .p-2 { - padding: calc(var(--spacing) * 2); - } .p-2\! { padding: calc(var(--spacing) * 2) !important; } @@ -510,9 +503,6 @@ .pt-4 { padding-top: calc(var(--spacing) * 4); } - .pb-0 { - padding-bottom: calc(var(--spacing) * 0); - } .pb-0\! { padding-bottom: calc(var(--spacing) * 0) !important; } @@ -531,9 +521,6 @@ .pb-8 { padding-bottom: calc(var(--spacing) * 8); } - .pb-10 { - padding-bottom: calc(var(--spacing) * 10); - } .pb-10\! { padding-bottom: calc(var(--spacing) * 10) !important; } @@ -570,9 +557,6 @@ --tw-font-weight: var(--font-weight-light); font-weight: var(--font-weight-light); } - .text-wrap { - text-wrap: wrap; - } .text-gray-50 { color: var(--color-gray-50); } @@ -588,17 +572,10 @@ .text-red-500 { color: var(--color-red-500); } - .underline { - text-decoration-line: underline; - } .shadow-lg { --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); diff --git a/views/paypal/index.html b/views/paypal/index.html index 9c8edfe..91fef5f 100644 --- a/views/paypal/index.html +++ b/views/paypal/index.html @@ -1,3 +1,21 @@ -

paypal

+

Pay with Paypal

+ + +

Form goes here:

+ +
+ +

+ + + + + + -

Replace me.