Quando ho migrato su Webround un e-commerce attivo da 4 anni, il cliente aveva un requisito non negoziabile: PayPal doveva funzionare. I suoi clienti erano abituati ad usarlo e toglierlo avrebbe significato perdere conversioni dal primo giorno.
Davo per scontato che fosse semplice, ma non lo era.
Webround usa Stripe Connect in modalità standard: la piattaforma trattiene una commissione, ma il merchant mantiene il proprio account Stripe e riceve i pagamenti direttamente. È un'architettura pulita, tiene Webround fuori da questioni non tecniche e non causa problemi.
Tranne con PayPal.
Stripe Connect in modalità standard non supporta PayPal a meno che tu non sia merchant of record, cioè che tu incassi i soldi per conto dei tuoi merchant e li ridistribuisci. È un modello di business completamente diverso, che implica tutta una serie di responsabilità. Mi sembrava strano, al punto tale da dover confermare con il supporto di Stripe. Dopo una chiamata telefonica (sì, Stripe ti chiama, ovunque tu sia), ho avuto la conferma che quello che serviva al mio cliente richiedeva un cambio di modello di responsabilità interno a Webround.
Non era una strada percorribile, ma fortunatamente, con Webround dalla mia parte, non è stato difficile arrivare a un compromesso.
La seconda idea che ho avuto era di integrare l'equivalente di Stripe Connect di PayPal (PayPal Marketplace) a livello di piattaforma, in modo che il merchant potesse collegare il proprio account PayPal nativamente. In teoria, aveva senso, in pratica un po' meno: c'è un processo di approvazione, bisogna parlare con qualcuno del reparto sales, raggiungere una soglia minima di volumi, e passare per un processo di onboarding da partner che richiede settimane. Non fattibile.
Terza opzione: costruire un flusso di checkout PayPal autonomo, che bypassasse sia Stripe che qualsiasi modello Connect. Il merchant usa direttamente il proprio account PayPal. In pratica, un normalissimo processo di pagamento PayPal da orchestrare nel modo corretto.
È da un po' di anni che mi affido a Cloudflare Workers per queste cose: leggero, distribuito all'edge, perfetto per integrazioni serverless stateless.
Ho pubblicato il codice completo su GitHub: github.com/WebroundAdmin/wr-paypal-integration. Puoi usarlo come punto di partenza.
created) e l'ordine PayPalpaidorder.paid verso le tue integrazioni esistentiNon fidarti mai dei prezzi inviati dal client, perché qualsiasi richiesta può manipolarli. Quando il cliente avvia il checkout, il Worker recupera il prezzo netto reale, l'aliquota fiscale e la disponibilità di magazzino per ogni prodotto, direttamente dall'API di Webround.
const verifiedItems = await Promise.all(
items.map(async item => {
const { unitNet, taxRate, packageWeight } = await fetchVariantData( // Metodo che chiama le API Webround
env,
item.variantId,
item.productId,
item.priceId,
currencyCode,
shippingAddress.countryCode
);
return { ...item, netAmount: unitNet, taxRate, packageWeight };
})
);Qui le cose si fanno interessanti.
Stripe e PayPal calcolano i totali degli ordini in modo diverso:
Stripe:
unitGross = round(netAmount × (1 + taxRate/100) × 100) / 100
lineTotal = unitGross × quantityPayPal:
lineTotal = round(netAmount × quantity × (1 + taxRate/100) × 100) / 100Questo è un dettaglio implementativo: puoi anche inviare direttamente il totale alla sessione di checkout di Stripe, ma nell'integrazione nativa di Stripe in Webround, la pagina di pagamento contiene il dettaglio di tutti i prodotti nel carrello: immagini, nome, prezzo totale e prezzo unitario. Quindi, Stripe, che ragiona in centesimi quando si paga in euro (EUR), ha bisogno di conoscere il totale comprensivo di aliquota fiscale per la singola unità, ancora prima di mostrare il totale su tutte le quantità acquistate.
Su un prodotto acquistato con singola unità, la differenza è zero. Su un carrello con prodotti che hanno quantità miste e prezzi decimali, i totali divergono.
L'ho scoperto in produzione. Non perché un cliente si fosse lamentato... il checkout funzionava correttamente dal suo punto di vista. L'ho trovato perché l'export degli ordini ricalcolava i totali server-side e i numeri non tornavano con quello che PayPal aveva effettivamente addebitato. C'erano discrepanze silenziose nell'export contabile.
Ti spiego con un esempio:
Prendiamo un prodotto con prezzo netto di €8.17 e aliquota IVA al 22%:
Lordo esatto: 8.17 × 1.22 = €9.9674
Metodo PayPal: moltiplica prima, arrotonda dopo:
9.9674 × 3 = 29.9022 → arrotondato: €29.90
Metodo Stripe: arrotonda prima, moltiplica dopo:
9.9674 → arrotondato a 2 decimali: €9.97
9.97 × 3 = €29.91
Una differenza di €0.01 per ordine. Silenzioso, sistematico, e impossibile da trovare nel happy-path testingIl fix: allineare il calcolo di PayPal al metodo di Stripe, che è l'integrazione nativa di Webround, quindi è PayPal ad adattarsi.
const grandTotal = verifiedItems.reduce((sum, item) => {
// Arrotondamento compatibile con Stripe: arrotonda prima il lordo unitario, poi moltiplica per la quantità
const unitGrossCents = Math.round(item.netAmount * (1 + item.taxRate / 100) * 100);
return sum + (unitGrossCents / 100) * item.quantity;
}, 0);Applica lo stesso metodo quando crei gli order items:
const unitGrossCents = Math.round(item.netAmount * (1 + item.taxRate / 100) * 100);
const unitGross = unitGrossCents / 100;
const unitNet = item.taxRate > 0 ? unitGross / (1 + item.taxRate / 100) : unitGross;
const unitTax = unitGross - unitNet;Scegli un metodo e applicalo in modo consistente ovunque. La discrepanza è esattamente quello che causa il bug.
const [wrCustomerId, paypalToken] = await Promise.all([
ensureCustomer(env, customerEmail, billingAddress, customerId),
getPayPalToken(env),
]);
const [wrOrder, paypalOrder] = await Promise.all([
createOrder(env, wrCustomerId, customerEmail, currencyCode, grandTotalStr, shippingCostStr, billingAddress, shippingAddress),
createPayPalOrder(env, paypalToken, grandTotalStr, currencyCode),
]);Salviamo la mappatura tra PayPal order ID e Webround order ID in una tabella Cloudflare D1: ci servirà quando arriverà il webhook.
await Promise.all([
createOrderItems(env, wrOrder.id, verifiedItems),
saveOrderMapping(env, paypalOrder.id, wrOrder.id),
]);Il Worker restituisce il PayPal order ID al frontend, che lo passa al PayPal JS SDK per aprire la pagina di pagamento.
Dopo che il cliente approva e il frontend cattura il pagamento, PayPal invia un evento webhook firmato. Questo è il segnale autorevole che il pagamento è andato a buon fine. Non fidarti mai di ciò che avviene nel client!
const body = await request.text();
const token = await getPayPalToken(env);
const valid = await verifyWebhookSignature(env, request, body, token);
if (!valid) return new Response("Unauthorized", { status: 401 });
const event = JSON.parse(body) as { event_type: string; resource: any };
const wrStatus = WEBHOOK_STATUS_MAP[event.event_type];
// PAYMENT.CAPTURE.COMPLETED → "paid"
// PAYMENT.CAPTURE.DENIED → "failed"
// CHECKOUT.ORDER.CANCELLED → "canceled"
const mapping = await getOrderMapping(env, paypalOrderId);
await Promise.all([
updateMappingStatus(env, paypalOrderId, wrStatus),
updateOrderStatus(env, mapping.wr_order_id, wrStatus),
]);Quando lo stato dell'ordine Webround diventa paid, Webround fa partire l'evento order.paid verso tutti i webhook che registrati: conferme email, aggiornamenti stock, sync CRM, tutto ascolta lì. Il flusso PayPal si aggancia a un sistema di eventi parallelo a quello nativo di Stripe, grazie all'architettura di Webround e i suoi webhook.
Sul lato Webround, questo è un componente React custom inserito nell'IDE integrato, aggiunto alla cart page sotto il pulsante di checkout nativo.
export default function PayPalButton({ wr }: { wr: Wr }) {
const [message, setMessage] = useState("");
const currencyCode = wr.customer.cart.items[0]?.currencyCode ?? "EUR";
const cartItems = wr.customer.cart.items.map((item) => ({
variantId: item.variantId,
productId: item.productId,
priceId: item.priceId,
quantity: item.quantity,
}));
const payload = {
items: cartItems,
currencyCode,
customerEmail: wr.cart.customerEmail,
customerId: wr.customer.isLoggedIn ? wr.customer.profile?.id : undefined,
customerJwt: wr.customer.isLoggedIn ? wr.customer.accessToken : undefined,
billingAddress: wr.cart.selectedBillingAddress,
shippingAddress: wr.cart.selectedShippingAddress,
};
return (
<PayPalScriptProvider options={{ clientId: PAYPAL_CLIENT_ID, currency: currencyCode }}>
<PayPalButtons
createOrder={async () => {
const res = await fetch(`${WORKER_URL}/api/orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.id) return data.id;
throw new Error(JSON.stringify(data));
}}
onApprove={async (data, actions) => {
const res = await fetch(`${WORKER_URL}/api/orders/${data.orderID}/capture`, {
method: "POST",
});
const orderData = await res.json();
const errorDetail = orderData?.details?.[0];
if (errorDetail?.issue === "INSTRUMENT_DECLINED") return actions.restart();
if (errorDetail) throw new Error(errorDetail.description);
window.location.href = "/checkout/success?clean_cart=true";
}}
/>
</PayPalScriptProvider>
);
}Il Worker è diventato la base dell'intero layer di pagamento del cliente al di fuori di Stripe. Separazione netta: Stripe ha il suo microservizio isolato, PayPal passa per il Worker. Entrambi convergono sugli ordini di Webround e fanno partire lo stesso evento order.paid downstream, con la differenza che PayPal fa tutto dall'esterno tramite API REST.
Il cliente ora fa girare entrambi i metodi di pagamento in parallelo, con copertura webhook completa e tracking degli eventi corretto su entrambi i lati.
Se stai costruendo su una piattaforma che usa Stripe Connect in modalità standard, non dare per scontato che PayPal sia a portata di plugin. Non lo è. Ma non è nemmeno impossibile: richiede solo di gestire l'orchestrazione in proprio.
I Cloudflare Workers sono una buona scelta per questo caso d'uso perché la logica è stateless, la latenza conta al checkout, e hai gestione dei secrets e D1 out of the box.
Testa l'arrotondamento prima di andare in produzione. Non emerge nel happy-path testing. Confronta esplicitamente i totali tra il calcolo della tua piattaforma e quello di PayPal, con dati di prodotto reali e quantità miste, prima di andare live.
Il codice completo del Worker, il componente React, lo schema D1 e la configurazione wrangler è su GitHub: github.com/WebroundAdmin/wr-paypal-integration
Costruito su Webround, una piattaforma e-commerce API-first. Tutti gli endpoint di ordini, catalogo e checkout sono documentati pubblicamente su docs.webround.com.