Come Abbiamo Costruito un Sistema di Pagamento Robusto con Stripe e PayPal: Un Approccio a Trifecta

Payment system with Stripe and PayPal

Prefazione

Da Forward Email, abbiamo sempre dato priorità alla creazione di sistemi affidabili, precisi e facili da usare. Quando si è trattato di implementare il nostro sistema di elaborazione pagamenti, sapevamo di aver bisogno di una soluzione in grado di gestire più processori di pagamento mantenendo una perfetta coerenza dei dati. Questo post del blog descrive come il nostro team di sviluppo ha integrato sia Stripe che PayPal utilizzando un approccio a trifecta che garantisce un'accuratezza 1:1 in tempo reale in tutto il nostro sistema.

La Sfida: Molteplici Processori di Pagamento, Una Sola Fonte di Verità

Come servizio email focalizzato sulla privacy, volevamo offrire ai nostri utenti opzioni di pagamento. Alcuni preferiscono la semplicità dei pagamenti con carta di credito tramite Stripe, mentre altri apprezzano il livello aggiuntivo di separazione fornito da PayPal. Tuttavia, supportare più processori di pagamento introduce una complessità significativa:

  1. Come garantiamo dati coerenti tra i diversi sistemi di pagamento?
  2. Come gestiamo casi limite come controversie, rimborsi o pagamenti falliti?
  3. Come manteniamo una sola fonte di verità nel nostro database?

La nostra soluzione è stata implementare ciò che chiamiamo "approccio a trifecta" - un sistema a tre livelli che fornisce ridondanza e garantisce la coerenza dei dati qualunque cosa accada.

L'Approccio a Trifecta: Tre Livelli di Affidabilità

Il nostro sistema di pagamento è composto da tre componenti critici che lavorano insieme per garantire una perfetta sincronizzazione dei dati:

  1. Reindirizzamenti post-checkout - Catturare le informazioni di pagamento immediatamente dopo il checkout
  2. Gestori webhook - Elaborare eventi in tempo reale dai processori di pagamento
  3. Job automatizzati - Verificare e riconciliare periodicamente i dati di pagamento

Esploriamo ciascun componente e vediamo come lavorano insieme.

Layer 1: Reindirizzamenti post-checkout

Il primo livello del nostro approccio trifasico avviene immediatamente dopo che un utente completa un pagamento. Sia Stripe che PayPal forniscono meccanismi per reindirizzare gli utenti al nostro sito con le informazioni sulla transazione.

Implementazione Stripe Checkout

Per Stripe, utilizziamo la loro API Checkout Sessions per creare un'esperienza di pagamento fluida. Quando un utente seleziona un piano e sceglie di pagare con carta di credito, creiamo una Checkout Session con URL specifici di successo e annullamento:

const options = {
  mode: paymentType === 'one-time' ? 'payment' : 'subscription',
  customer: ctx.state.user[config.userFields.stripeCustomerID],
  client_reference_id: reference,
  metadata: {
    plan
  },
  line_items: [
    {
      price,
      quantity: 1,
      description
    }
  ],
  locale: config.STRIPE_LOCALES.has(ctx.locale) ? ctx.locale : 'auto',
  cancel_url: `${config.urls.web}${ctx.path}${
    isMakePayment || isEnableAutoRenew ? '' : `/?plan=${plan}`
  }`,
  success_url: `${config.urls.web}${ctx.path}/?${
    isMakePayment || isEnableAutoRenew ? '' : `plan=${plan}&`
  }session_id={CHECKOUT_SESSION_ID}`,
  allow_promotion_codes: true
};

// Create the checkout session and redirect
const session = await stripe.checkout.sessions.create(options);
const redirectTo = session.url;
if (ctx.accepts('html')) {
  ctx.status = 303;
  ctx.redirect(redirectTo);
} else {
  ctx.body = { redirectTo };
}

La parte critica qui è il parametro success_url, che include il session_id come parametro di query. Quando Stripe reindirizza l'utente al nostro sito dopo un pagamento riuscito, possiamo usare questo ID di sessione per verificare la transazione e aggiornare di conseguenza il nostro database.

Flusso di pagamento PayPal

Per PayPal, utilizziamo un approccio simile con la loro API Orders:

const requestBody = {
  intent: 'CAPTURE',
  application_context: {
    cancel_url: `${config.urls.web}${ctx.path}${
      isMakePayment || isEnableAutoRenew ? '' : `/?plan=${plan}`
    }`,
    return_url: `${config.urls.web}${ctx.path}/?plan=${plan}`,
    brand_name: 'Forward Email',
    shipping_preference: 'NO_SHIPPING',
    user_action: 'PAY_NOW'
  },
  payer: {
    email_address: ctx.state.user.email
  },
  purchase_units: [
    {
      reference_id: ctx.state.user.id,
      description,
      custom_id: sku,
      invoice_id: reference,
      soft_descriptor: sku,
      amount: {
        currency_code: 'USD',
        value: price,
        breakdown: {
          item_total: {
            currency_code: 'USD',
            value: price
          }
        }
      },
      items: [
        {
          name,
          description,
          sku,
          unit_amount: {
            currency_code: 'USD',
            value: price
          },
          quantity: '1',
          category: 'DIGITAL_GOODS'
        }
      ]
    }
  ]
};

Simile a Stripe, specifichiamo i parametri return_url e cancel_url per gestire i reindirizzamenti post-pagamento. Quando PayPal reindirizza l'utente al nostro sito, possiamo acquisire i dettagli del pagamento e aggiornare il nostro database.

Layer 2: Gestori Webhook con Verifica della Firma

Sebbene i reindirizzamenti post-checkout funzionino bene nella maggior parte degli scenari, non sono infallibili. Gli utenti potrebbero chiudere il browser prima del reindirizzamento, oppure problemi di rete potrebbero impedire il completamento del reindirizzamento. È qui che entrano in gioco i webhook.

Sia Stripe che PayPal forniscono sistemi di webhook che inviano notifiche in tempo reale sugli eventi di pagamento. Abbiamo implementato gestori webhook robusti che verificano l'autenticità di queste notifiche e le elaborano di conseguenza.

Implementazione Webhook Stripe

Il nostro gestore webhook di Stripe verifica la firma degli eventi webhook in arrivo per assicurarsi che siano legittimi:

async function webhook(ctx) {
  const sig = ctx.request.get('stripe-signature');
  // throw an error if something was wrong
  if (!isSANB(sig))
    throw Boom.badRequest(ctx.translateError('INVALID_STRIPE_SIGNATURE'));
  const event = stripe.webhooks.constructEvent(
    ctx.request.rawBody,
    sig,
    env.STRIPE_ENDPOINT_SECRET
  );
  // throw an error if something was wrong
  if (!event)
    throw Boom.badRequest(ctx.translateError('INVALID_STRIPE_SIGNATURE'));
  ctx.logger.info('stripe webhook', { event });
  // return a response to acknowledge receipt of the event
  ctx.body = { received: true };
  // run in background
  processEvent(ctx, event)
    .then()
    .catch((err) => {
      ctx.logger.fatal(err, { event });
      // email admin errors
      emailHelper({
        template: 'alert',
        message: {
          to: config.email.message.from,
          subject: `Errore con Webhook Stripe (ID Evento ${event.id})`
        },
        locals: {
          message: `<pre><code>${safeStringify(
            parseErr(err),
            null,
            2
          )}</code></pre>`
        }
      })
        .then()
        .catch((err) => ctx.logger.fatal(err, { event }));
    });
}

La funzione stripe.webhooks.constructEvent verifica la firma utilizzando il nostro segreto dell'endpoint. Se la firma è valida, elaboriamo l'evento in modo asincrono per evitare di bloccare la risposta del webhook.

Implementazione Webhook PayPal

Analogamente, il nostro gestore webhook di PayPal verifica l'autenticità delle notifiche in arrivo:

async function webhook(ctx) {
  const response = await promisify(
    paypal.notification.webhookEvent.verify,
    paypal.notification.webhookEvent
  )(ctx.request.headers, ctx.request.body, env.PAYPAL_WEBHOOK_ID);
  // throw an error if something was wrong
  if (!_.isObject(response) || response.verification_status !== 'SUCCESS')
    throw Boom.badRequest(ctx.translateError('INVALID_PAYPAL_SIGNATURE'));
  // return a response to acknowledge receipt of the event
  ctx.body = { received: true };
  // run in background
  processEvent(ctx)
    .then()
    .catch((err) => {
      ctx.logger.fatal(err);
      // email admin errors
      emailHelper({
        template: 'alert',
        message: {
          to: config.email.message.from,
          subject: `Errore con Webhook PayPal (ID Evento ${ctx.request.body.id})`
        },
        locals: {
          message: `<pre><code>${safeStringify(
            parseErr(err),
            null,
            2
          )}</code></pre>`
        }
      })
        .then()
        .catch((err) => ctx.logger.fatal(err));
    });
}

Entrambi i gestori webhook seguono lo stesso schema: verificano la firma, riconoscono la ricezione e processano l'evento in modo asincrono. Questo garantisce che non perdiamo mai un evento di pagamento, anche se il reindirizzamento post-checkout fallisce.

Layer 3: Lavori Automatizzati con Bree

L'ultimo livello del nostro approccio trifasico è un insieme di lavori automatizzati che verificano e riconciliano periodicamente i dati di pagamento. Utilizziamo Bree, un job scheduler per Node.js, per eseguire questi lavori a intervalli regolari.

Controllo di Accuratezza delle Sottoscrizioni

Uno dei nostri lavori chiave è il controllo di accuratezza delle sottoscrizioni, che assicura che il nostro database rifletta accuratamente lo stato delle sottoscrizioni in Stripe:

async function mapper(customer) {
  // wait a second to prevent rate limitation error
  await setTimeout(ms('1s'));
  // check for user on our side
  let user = await Users.findOne({
    [config.userFields.stripeCustomerID]: customer.id
  })
    .lean()
    .exec();
  if (!user) return;
  if (user.is_banned) return;

  // if emails did not match
  if (user.email !== customer.email) {
    logger.info(
      `User email ${user.email} did not match customer email ${customer.email} (${customer.id})`
    );
    customer = await stripe.customers.update(customer.id, {
      email: user.email
    });
    logger.info(`Updated user email to match ${user.email}`);
  }

  // check for active subscriptions
  const [activeSubscriptions, trialingSubscriptions] = await Promise.all([
    stripe.subscriptions.list({
      customer: customer.id,
      status: 'active'
    }),
    stripe.subscriptions.list({
      customer: customer.id,
      status: 'trialing'
    })
  ]);

  // Combine active and trialing subscriptions
  let subscriptions = [
    ...activeSubscriptions.data,
    ...trialingSubscriptions.data
  ];

  // Handle edge case: multiple subscriptions for one user
  if (subscriptions.length > 1) {
    await logger.error(
      new Error(
        `We may need to refund: User had multiple subscriptions ${user.email} (${customer.id})`
      )
    );
    await emailHelper({
      template: 'alert',
      message: {
        to: config.email.message.from,
        subject: `User had multiple subscriptions ${user.email}`
      },
      locals: {
        message: `User ${user.email} (${customer.id}) had multiple subscriptions: ${JSON.stringify(
          subscriptions.map((s) => s.id)
        )}`
      }
    });
  }
}

This job checks for discrepancies between our database and Stripe, such as mismatched email addresses or multiple active subscriptions. If it finds any issues, it logs them and sends alerts to our admin team.

PayPal Subscription Synchronization

We have a similar job for PayPal subscriptions:

async function syncPayPalSubscriptionPayments() {
  const paypalCustomers = await Users.find({
    $or: [
      {
        [config.userFields.paypalSubscriptionID]: { $exists: true, $ne: null }
      },
      {
        [config.userFields.paypalPayerID]: { $exists: true, $ne: null }
      }
    ]
  })
    // sort by newest customers first
    .sort('-created_at')
    .lean()
    .exec();

  await logger.info(
    `Syncing payments for ${paypalCustomers.length} paypal customers`
  );

  // Process each customer and sync their payments
  const errorEmails = await pReduce(
    paypalCustomers,
    // Implementation details...
  );
}

These automated jobs serve as our final safety net, ensuring that our database always reflects the true state of subscriptions and payments in both Stripe and PayPal.

Handling Edge Cases

A robust payment system must handle edge cases gracefully. Let's look at how we handle some common scenarios.

Fraud Detection and Prevention

We've implemented sophisticated fraud detection mechanisms that automatically identify and handle suspicious payment activities:

case 'charge.failed': {
  // Get all failed charges in the last 30 days
  const charges = await stripe.charges.list({
    customer: event.data.object.customer,
    created: {
      gte: dayjs().subtract(1, 'month').unix()
    }
  });

  // Filter for declined charges
  const filtered = charges.data.filter(
    (d) => d.status === 'failed' && d.failure_code === 'card_declined'
  );

  // if not more than 5 then return early
  if (filtered.length < 5) break;

  // Check if user has verified domains
  const count = await Domains.countDocuments({
    members: {
      $elemMatch: {
        user: user._id,
        group: 'admin'
      }
    },
    plan: { $in: ['enhanced_protection', 'team'] },
    has_txt_record: true
  });

  if (!user.is_banned) {
    // If no verified domains, ban the user and refund all charges
    if (count === 0) {
      // Ban the user
      user.is_banned = true;
      await user.save();

      // Refund all successful charges
    }
  }
}

Questo codice banna automaticamente gli utenti che hanno molteplici addebiti falliti e nessun dominio verificato, il che è un forte indicatore di attività fraudolenta.

Gestione delle contestazioni

Quando un utente contesta un addebito, accettiamo automaticamente la richiesta e prendiamo le azioni appropriate:

case 'CUSTOMER.DISPUTE.CREATED': {
  // accetta la richiesta
  const agent = await paypalAgent();
  await agent
    .post(`/v1/customer/disputes/${body.resource.dispute_id}/accept-claim`)
    .send({
      note: 'Rimborso completo al cliente.'
    });

  // Trova il pagamento nel nostro database
  const payment = await Payments.findOne({ $or });
  if (!payment) throw new Error('Il pagamento non esiste');

  const user = await Users.findById(payment.user);
  if (!user) throw new Error('L\'utente non esisteva per il cliente');

  // Annulla l'abbonamento dell'utente se ne ha uno
  if (isSANB(user[config.userFields.paypalSubscriptionID])) {
    try {
      const agent = await paypalAgent();
      await agent.post(
        `/v1/billing/subscriptions/${
          user[config.userFields.paypalSubscriptionID]
        }/cancel`
      );
    } catch (err) {
      // Gestisci gli errori di cancellazione dell'abbonamento
    }
  }
}

Questo approccio minimizza l'impatto delle contestazioni sul nostro business garantendo al contempo una buona esperienza per il cliente.

Riutilizzo del codice: Principi KISS e DRY

Nel nostro sistema di pagamento, abbiamo seguito i principi KISS (Keep It Simple, Stupid) e DRY (Don't Repeat Yourself). Ecco alcuni esempi:

  1. Funzioni di supporto condivise: Abbiamo creato funzioni di supporto riutilizzabili per attività comuni come la sincronizzazione dei pagamenti e l'invio di email.

  2. Gestione degli errori coerente: Sia i gestori webhook di Stripe che di PayPal utilizzano lo stesso schema per la gestione degli errori e le notifiche agli amministratori.

  3. Schema di database unificato: Il nostro schema di database è progettato per gestire sia i dati di Stripe che di PayPal, con campi comuni per stato del pagamento, importo e informazioni sul piano.

  4. Configurazione centralizzata: La configurazione relativa ai pagamenti è centralizzata in un unico file, rendendo facile aggiornare prezzi e informazioni sui prodotti.

graph TD subgraph "Principio DRY" V[Logica Condivisa] --> W[Funzioni di Elaborazione Pagamenti] V --> X[Modelli Email] V --> Y[Logica di Validazione]

    Z[Operazioni Comuni sul Database] --> AA[Aggiornamenti Utente]
    Z --> AB[Registrazione Pagamenti]
end

classDef primary fill:blue,stroke:#333,stroke-width:2px;
classDef secondary fill:red,stroke:#333,stroke-width:1px;

class A,P,V primary;
class B,C,D,E,I,L,Q,R,S,W,X,Y,Z secondary;


## Implementazione dei Requisiti di Abbonamento VISA {#visa-subscription-requirements-implementation}

Oltre al nostro approccio trifasico, abbiamo implementato funzionalità specifiche per conformarci ai requisiti di abbonamento VISA migliorando al contempo l'esperienza utente. Un requisito chiave di VISA è che gli utenti devono essere notificati prima di essere addebitati per un abbonamento, specialmente durante la transizione da una prova gratuita a un abbonamento a pagamento.

### Notifiche Email Automatiche Pre-Rinnovo {#automated-pre-renewal-email-notifications}

Abbiamo creato un sistema automatico che identifica gli utenti con abbonamenti di prova attivi e invia loro una email di notifica prima che avvenga il primo addebito. Questo non solo ci mantiene conformi ai requisiti VISA, ma riduce anche i chargeback e migliora la soddisfazione del cliente.

Ecco come abbiamo implementato questa funzionalità:

```javascript
// Trova utenti con abbonamenti di prova che non hanno ancora ricevuto una notifica
const users = await Users.find({
  $or: [
    {
      $and: [
        { [config.userFields.stripeSubscriptionID]: { $exists: true } },
        { [config.userFields.stripeTrialSentAt]: { $exists: false } },
        // Escludi abbonamenti che hanno già avuto pagamenti
        ...(paidStripeSubscriptionIds.length > 0
          ? [
              {
                [config.userFields.stripeSubscriptionID]: {
                  $nin: paidStripeSubscriptionIds
                }
              }
            ]
          : [])
      ]
    },
    {
      $and: [
        { [config.userFields.paypalSubscriptionID]: { $exists: true } },
        { [config.userFields.paypalTrialSentAt]: { $exists: false } },
        // Escludi abbonamenti che hanno già avuto pagamenti
        ...(paidPayPalSubscriptionIds.length > 0
          ? [
              {
                [config.userFields.paypalSubscriptionID]: {
                  $nin: paidPayPalSubscriptionIds
                }
              }
            ]
          : [])
      ]
    }
  ]
});

// Processa ogni utente e invia la notifica
for (const user of users) {
  // Ottieni dettagli dell'abbonamento dal processore di pagamento
  const subscription = await getSubscriptionDetails(user);

  // Calcola durata e frequenza dell'abbonamento
  const duration = getDurationFromPlanId(subscription.plan_id);
  const frequency = getHumanReadableFrequency(duration, user.locale);
  const amount = getPlanAmount(user.plan, duration);

  // Ottieni i domini dell'utente per email personalizzata
  const domains = await Domains.find({
    'members.user': user._id
  }).sort('name').lean().exec();

  // Invia email di notifica conforme a VISA
  await emailHelper({
    template: 'visa-trial-subscription-requirement',
    message: {
      to: user.receipt_email || user.email,
      ...(user.receipt_email ? { cc: user.email } : {})
    },
    locals: {
      user,
      firstChargeDate: new Date(subscription.start_time),
      frequency,
      formattedAmount: numeral(amount).format('$0,0,0.00'),
      domains
    }
  });

  // Registra che la notifica è stata inviata
  await Users.findByIdAndUpdate(user._id, {
    $set: {
      [config.userFields.paypalTrialSentAt]: new Date()
    }
  });
}

Questa implementazione garantisce che gli utenti siano sempre informati sugli addebiti imminenti, con dettagli chiari su:

  1. Quando avverrà il primo addebito
  2. La frequenza degli addebiti futuri (mensile, annuale, ecc.)
  3. L'importo esatto che verrà addebitato
  4. Quali domini sono coperti dal loro abbonamento

Automatizzando questo processo, manteniamo una perfetta conformità ai requisiti VISA (che impongono la notifica almeno 7 giorni prima dell'addebito) riducendo al contempo le richieste di supporto e migliorando l'esperienza utente complessiva.

Gestione dei Casi Limite

La nostra implementazione include anche una gestione robusta degli errori. Se qualcosa va storto durante il processo di notifica, il nostro sistema avvisa automaticamente il nostro team:

try {
  await mapper(user);
} catch (err) {
  logger.error(err);

  // Invia avviso agli amministratori
  await emailHelper({
    template: 'alert',
    message: {
      to: config.email.message.from,
      subject: 'Errore nei Requisiti di Abbonamento di Prova VISA'
    },
    locals: {
      message: `<pre><code>${safeStringify(
        parseErr(err),
        null,
        2
      )}</code></pre>`
    }
  });
}

Questo garantisce che anche se c'è un problema con il sistema di notifica, il nostro team possa intervenire rapidamente e mantenere la conformità ai requisiti VISA.

Il sistema di notifica per l’abbonamento VISA è un altro esempio di come abbiamo costruito la nostra infrastruttura di pagamento tenendo conto sia della conformità che dell’esperienza utente, completando il nostro approccio trifecta per garantire un’elaborazione dei pagamenti affidabile e trasparente.

Periodi di Prova e Termini di Abbonamento

Per gli utenti che attivano il rinnovo automatico su piani esistenti, calcoliamo il periodo di prova appropriato per garantire che non vengano addebitati fino alla scadenza del loro piano attuale:

if (
  isEnableAutoRenew &&
  dayjs(ctx.state.user[config.userFields.planExpiresAt]).isAfter(
    dayjs()
  )
) {
  const hours = dayjs(
    ctx.state.user[config.userFields.planExpiresAt]
  ).diff(dayjs(), 'hours');

  // Gestione del calcolo del periodo di prova
}

Forniamo inoltre informazioni chiare sui termini di abbonamento, inclusa la frequenza di fatturazione e le politiche di cancellazione, e includiamo metadati dettagliati con ogni abbonamento per garantire un corretto tracciamento e gestione.

Conclusione: I Vantaggi del Nostro Approccio Trifecta

Il nostro approccio trifecta all’elaborazione dei pagamenti ha fornito diversi vantaggi chiave:

  1. Affidabilità: Implementando tre livelli di verifica del pagamento, garantiamo che nessun pagamento venga perso o elaborato in modo errato.

  2. Precisione: Il nostro database riflette sempre lo stato reale degli abbonamenti e dei pagamenti sia in Stripe che in PayPal.

  3. Flessibilità: Gli utenti possono scegliere il metodo di pagamento preferito senza compromettere l’affidabilità del nostro sistema.

  4. Robustezza: Il nostro sistema gestisce con eleganza i casi limite, dai guasti di rete alle attività fraudolente.

Se stai implementando un sistema di pagamento che supporta più processori, consigliamo vivamente questo approccio trifecta. Richiede un maggiore sforzo di sviluppo iniziale, ma i benefici a lungo termine in termini di affidabilità e precisione ne valgono sicuramente la pena.

Per maggiori informazioni su Forward Email e i nostri servizi email focalizzati sulla privacy, visita il nostro sito web.