Hvordan vi byggede et robust betalingssystem med Stripe og PayPal: En trifecta-tilgang
Forord
Hos Forward Email har vi altid prioriteret at skabe systemer, der er pålidelige, præcise og brugervenlige. Da vi skulle implementere vores betalingsbehandlingssystem, vidste vi, at vi havde brug for en løsning, der kunne håndtere flere betalingsprocessorer samtidig med at opretholde perfekt datakonsistens. Dette blogindlæg beskriver, hvordan vores udviklingsteam integrerede både Stripe og PayPal ved hjælp af en trifecta-tilgang, der sikrer 1:1 realtidsnøjagtighed på tværs af hele vores system.
Udfordringen: Flere betalingsprocessorer, én sandhedskilde
Som en privatlivsfokuseret e-mailtjeneste ønskede vi at give vores brugere betalingsmuligheder. Nogle foretrækker enkelheden ved kreditkortbetalinger gennem Stripe, mens andre værdsætter det ekstra lag af adskillelse, som PayPal giver. Men understøttelse af flere betalingsprocessorer introducerer betydelig kompleksitet:
- Hvordan sikrer vi konsistente data på tværs af forskellige betalingssystemer?
- Hvordan håndterer vi kanttilfælde som tvister, refunderinger eller mislykkede betalinger?
- Hvordan opretholder vi én sandhedskilde i vores database?
Vores løsning var at implementere det, vi kalder "trifecta-tilgangen" – et tre-lags system, der giver redundans og sikrer datakonsistens uanset hvad der sker.
Trifecta-tilgangen: Tre lag af pålidelighed
Vores betalingssystem består af tre kritiske komponenter, der arbejder sammen for at sikre perfekt datasynkronisering:
- Post-checkout redirects – Indfangning af betalingsinformation umiddelbart efter checkout
- Webhook-handlere – Behandling af realtidsbegivenheder fra betalingsprocessorer
- Automatiserede jobs – Periodisk verifikation og afstemning af betalingsdata
Lad os dykke ned i hver komponent og se, hvordan de arbejder sammen.
Lag 1: Omdirigeringer efter checkout
Det første lag i vores trefoldige tilgang sker umiddelbart efter, at en bruger har gennemført en betaling. Både Stripe og PayPal tilbyder mekanismer til at omdirigere brugere tilbage til vores side med transaktionsinformation.
Stripe Checkout-implementering
For Stripe bruger vi deres Checkout Sessions API til at skabe en problemfri betalingsoplevelse. Når en bruger vælger en plan og vælger at betale med kreditkort, opretter vi en Checkout Session med specifikke succes- og annullerings-URL'er:
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 };
}
Den kritiske del her er success_url-parameteren, som inkluderer session_id som en forespørgselsparameter. Når Stripe omdirigerer brugeren tilbage til vores side efter en vellykket betaling, kan vi bruge denne session-ID til at verificere transaktionen og opdatere vores database tilsvarende.
PayPal betalingsflow
For PayPal bruger vi en lignende tilgang med deres Orders API:
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'
}
]
}
]
};
Ligesom med Stripe specificerer vi return_url og cancel_url parametre for at håndtere omdirigeringer efter betaling. Når PayPal omdirigerer brugeren tilbage til vores side, kan vi indfange betalingsdetaljerne og opdatere vores database.
Lag 2: Webhook-håndteringer med signaturverifikation
Mens post-checkout omdirigeringer fungerer godt i de fleste scenarier, er de ikke idiotsikre. Brugere kan lukke deres browser, før de bliver omdirigeret, eller netværksproblemer kan forhindre, at omdirigeringen fuldføres. Her kommer webhooks ind i billedet.
Både Stripe og PayPal tilbyder webhook-systemer, der sender realtidsnotifikationer om betalingsbegivenheder. Vi har implementeret robuste webhook-håndteringer, der verificerer ægtheden af disse notifikationer og behandler dem derefter.
Stripe Webhook-implementering
Vores Stripe webhook-håndtering verificerer signaturen på indkommende webhook-begivenheder for at sikre, at de er legitime:
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: `Error with Stripe Webhook (Event ID ${event.id})`
},
locals: {
message: `<pre><code>${safeStringify(
parseErr(err),
null,
2
)}</code></pre>`
}
})
.then()
.catch((err) => ctx.logger.fatal(err, { event }));
});
}
Funktionen stripe.webhooks.constructEvent verificerer signaturen ved hjælp af vores endpoint-secret. Hvis signaturen er gyldig, behandler vi begivenheden asynkront for at undgå at blokere webhook-responsen.
PayPal Webhook-implementering
På samme måde verificerer vores PayPal webhook-håndtering ægtheden af indkommende notifikationer:
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: `Error with PayPal Webhook (Event ID ${ctx.request.body.id})`
},
locals: {
message: `<pre><code>${safeStringify(
parseErr(err),
null,
2
)}</code></pre>`
}
})
.then()
.catch((err) => ctx.logger.fatal(err));
});
}
Begge webhook-håndteringer følger samme mønster: verificer signaturen, bekræft modtagelsen, og behandl begivenheden asynkront. Dette sikrer, at vi aldrig går glip af en betalingsbegivenhed, selv hvis post-checkout omdirigeringen fejler.
Lag 3: Automatiserede jobs med Bree
Det sidste lag i vores trefoldige tilgang er et sæt automatiserede jobs, der periodisk verificerer og afstemmer betalingsdata. Vi bruger Bree, en jobplanlægger til Node.js, til at køre disse jobs med jævne mellemrum.
Kontrol af abonnementsnøjagtighed
Et af vores nøglejobs er abonnementsnøjagtighedskontrollen, som sikrer, at vores database nøjagtigt afspejler abonnementsstatus i 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
}
}
}
Denne kode blokerer automatisk brugere, der har flere mislykkede betalinger og ingen verificerede domæner, hvilket er en stærk indikator på svigagtig aktivitet.
Dispute Handling
Når en bruger bestrider en betaling, accepterer vi automatisk kravet og tager passende handling:
case 'CUSTOMER.DISPUTE.CREATED': {
// accept claim
const agent = await paypalAgent();
await agent
.post(`/v1/customer/disputes/${body.resource.dispute_id}/accept-claim`)
.send({
note: 'Fuld refundering til kunden.'
});
// Find betalingen i vores database
const payment = await Payments.findOne({ $or });
if (!payment) throw new Error('Betaling findes ikke');
const user = await Users.findById(payment.user);
if (!user) throw new Error('Bruger eksisterede ikke for kunden');
// Annuller brugerens abonnement, hvis de har et
if (isSANB(user[config.userFields.paypalSubscriptionID])) {
try {
const agent = await paypalAgent();
await agent.post(
`/v1/billing/subscriptions/${
user[config.userFields.paypalSubscriptionID]
}/cancel`
);
} catch (err) {
// Håndter fejl ved annullering af abonnement
}
}
}
Denne tilgang minimerer virkningen af tvister på vores forretning, samtidig med at den sikrer en god kundeoplevelse.
Code Reuse: KISS and DRY Principles
Gennem hele vores betalingssystem har vi fulgt KISS (Keep It Simple, Stupid) og DRY (Don't Repeat Yourself) principperne. Her er nogle eksempler:
-
Delte hjælpefunktioner: Vi har oprettet genanvendelige hjælpefunktioner til almindelige opgaver som synkronisering af betalinger og afsendelse af e-mails.
-
Konsistent fejlhåndtering: Både Stripe og PayPal webhook-handlere bruger det samme mønster for fejlhåndtering og admin-notifikationer.
-
Enheds database-skema: Vores databaseskema er designet til at rumme både Stripe- og PayPal-data med fælles felter for betalingsstatus, beløb og planinformation.
-
Centraliseret konfiguration: Betalingsrelateret konfiguration er centraliseret i en enkelt fil, hvilket gør det nemt at opdatere priser og produktinformation.
graph TD subgraph "DRY Principle" V[Shared Logic] --> W[Payment Processing Functions] V --> X[Email Templates] V --> Y[Validation Logic]
Z[Common Database Operations] --> AA[User Updates]
Z --> AB[Payment Recording]
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;
## Implementering af VISA-abonnements krav {#visa-subscription-requirements-implementation}
Ud over vores trefoldige tilgang har vi implementeret specifikke funktioner for at overholde VISAs abonnements krav samtidig med, at brugeroplevelsen forbedres. Et nøglekrav fra VISA er, at brugerne skal underrettes, før de bliver opkrævet for et abonnement, især når de går fra en prøveperiode til et betalt abonnement.
### Automatiserede e-mail notifikationer før fornyelse {#automated-pre-renewal-email-notifications}
Vi har bygget et automatiseret system, der identificerer brugere med aktive prøveabonnementer og sender dem en notifikationsmail, før deres første opkrævning finder sted. Dette sikrer ikke kun, at vi overholder VISAs krav, men reducerer også tilbageførsler og forbedrer kundetilfredsheden.
Sådan har vi implementeret denne funktion:
```javascript
// Find users with trial subscriptions who haven't received a notification yet
const users = await Users.find({
$or: [
{
$and: [
{ [config.userFields.stripeSubscriptionID]: { $exists: true } },
{ [config.userFields.stripeTrialSentAt]: { $exists: false } },
// Exclude subscriptions that have already had payments
...(paidStripeSubscriptionIds.length > 0
? [
{
[config.userFields.stripeSubscriptionID]: {
$nin: paidStripeSubscriptionIds
}
}
]
: [])
]
},
{
$and: [
{ [config.userFields.paypalSubscriptionID]: { $exists: true } },
{ [config.userFields.paypalTrialSentAt]: { $exists: false } },
// Exclude subscriptions that have already had payments
...(paidPayPalSubscriptionIds.length > 0
? [
{
[config.userFields.paypalSubscriptionID]: {
$nin: paidPayPalSubscriptionIds
}
}
]
: [])
]
}
]
});
// Process each user and send notification
for (const user of users) {
// Get subscription details from payment processor
const subscription = await getSubscriptionDetails(user);
// Calculate subscription duration and frequency
const duration = getDurationFromPlanId(subscription.plan_id);
const frequency = getHumanReadableFrequency(duration, user.locale);
const amount = getPlanAmount(user.plan, duration);
// Get user's domains for personalized email
const domains = await Domains.find({
'members.user': user._id
}).sort('name').lean().exec();
// Send VISA-compliant notification email
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
}
});
// Record that notification was sent
await Users.findByIdAndUpdate(user._id, {
$set: {
[config.userFields.paypalTrialSentAt]: new Date()
}
});
}
Denne implementering sikrer, at brugerne altid bliver informeret om kommende opkrævninger med klare oplysninger om:
- Hvornår den første opkrævning vil finde sted
- Hyppigheden af fremtidige opkrævninger (månedligt, årligt osv.)
- Det præcise beløb, de vil blive opkrævet
- Hvilke domæner der er dækket af deres abonnement
Ved at automatisere denne proces opretholder vi fuld overensstemmelse med VISAs krav (som kræver underretning mindst 7 dage før opkrævning), samtidig med at vi reducerer supporthenvendelser og forbedrer den samlede brugeroplevelse.
Håndtering af Kanttilfælde
Vores implementering inkluderer også robust fejlhåndtering. Hvis noget går galt under notifikationsprocessen, alarmerer vores system automatisk vores team:
try {
await mapper(user);
} catch (err) {
logger.error(err);
// Send alert to administrators
await emailHelper({
template: 'alert',
message: {
to: config.email.message.from,
subject: 'VISA Trial Subscription Requirement Error'
},
locals: {
message: `<pre><code>${safeStringify(
parseErr(err),
null,
2
)}</code></pre>`
}
});
}
Dette sikrer, at selv hvis der opstår et problem med notifikationssystemet, kan vores team hurtigt tage hånd om det og opretholde overholdelse af VISAs krav.
VISA abonnement notifikationssystemet er et andet eksempel på, hvordan vi har bygget vores betalingsinfrastruktur med både overholdelse og brugeroplevelse for øje, som supplerer vores trifecta-tilgang for at sikre pålidelig, gennemsigtig betalingsbehandling.
Prøveperioder og Abonnementsbetingelser
For brugere, der aktiverer automatisk fornyelse på eksisterende planer, beregner vi den passende prøveperiode for at sikre, at de ikke bliver opkrævet, før deres nuværende plan udløber:
if (
isEnableAutoRenew &&
dayjs(ctx.state.user[config.userFields.planExpiresAt]).isAfter(
dayjs()
)
) {
const hours = dayjs(
ctx.state.user[config.userFields.planExpiresAt]
).diff(dayjs(), 'hours');
// Handle trial period calculation
}
Vi giver også klar information om abonnementsbetingelser, inklusive faktureringsfrekvens og afbestillingspolitikker, og inkluderer detaljeret metadata med hvert abonnement for at sikre korrekt sporing og administration.
Konklusion: Fordelene ved Vores Trifecta-Tilgang
Vores trifecta-tilgang til betalingsbehandling har givet flere nøglefordele:
-
Pålidelighed: Ved at implementere tre lag af betalingsverifikation sikrer vi, at ingen betalinger bliver overset eller forkert behandlet.
-
Nøjagtighed: Vores database afspejler altid den sande tilstand af abonnementer og betalinger i både Stripe og PayPal.
-
Fleksibilitet: Brugere kan vælge deres foretrukne betalingsmetode uden at gå på kompromis med systemets pålidelighed.
-
Robusthed: Vores system håndterer kanttilfælde elegant, fra netværksfejl til svigagtige aktiviteter.
Hvis du implementerer et betalingssystem, der understøtter flere processorer, anbefaler vi stærkt denne trifecta-tilgang. Det kræver mere udviklingsindsats i starten, men de langsigtede fordele i form af pålidelighed og nøjagtighed er det hele værd.
For mere information om Forward Email og vores privatlivsfokuserede e-mailtjenester, besøg vores hjemmeside.