Hogyan építettünk ki egy robusztus fizetési rendszert Stripe és PayPal segítségével: Egy trifecta megközelítés
Előszó
A Forward Email-nél mindig is elsődleges célunk volt megbízható, pontos és felhasználóbarát rendszerek létrehozása. Amikor a fizetési feldolgozó rendszerünk megvalósítására került sor, tudtuk, hogy olyan megoldásra van szükségünk, amely képes több fizetési feldolgozó kezelésére, miközben tökéletes adatkonzisztenciát tart fenn. Ez a blogbejegyzés részletezi, hogyan integrálta fejlesztőcsapatunk a Stripe-ot és a PayPalt egy trifecta megközelítéssel, amely 1:1 valós idejű pontosságot biztosít az egész rendszerünkben.
A kihívás: Több fizetési feldolgozó, egyetlen igazságforrás
Adatvédelmi fókuszú email szolgáltatóként szerettünk volna fizetési lehetőségeket kínálni felhasználóinknak. Egyesek a Stripe-on keresztüli bankkártyás fizetés egyszerűségét részesítik előnyben, míg mások értékelik a PayPal által nyújtott további elkülönítési réteget. Azonban több fizetési feldolgozó támogatása jelentős bonyolultságot hoz magával:
- Hogyan biztosítsuk az adatok konzisztenciáját a különböző fizetési rendszerek között?
- Hogyan kezeljük a szélsőséges eseteket, mint a viták, visszatérítések vagy sikertelen fizetések?
- Hogyan tartsunk fenn egyetlen igazságforrást az adatbázisunkban?
Megoldásunk az úgynevezett "trifecta megközelítés" bevezetése volt – egy háromrétegű rendszer, amely redundanciát biztosít és garantálja az adatkonzisztenciát bármi történjék is.
A trifecta megközelítés: Három megbízhatósági réteg
Fizetési rendszerünk három kritikus összetevőből áll, amelyek együttműködve biztosítják a tökéletes adat szinkronizációt:
- Vásárlás utáni átirányítások – A fizetési információk azonnali rögzítése a vásárlás után
- Webhook kezelők – Valós idejű események feldolgozása a fizetési feldolgozóktól
- Automatikus feladatok – Időszakos ellenőrzés és egyeztetés a fizetési adatok között
Nézzük meg részletesen az egyes összetevőket és működésüket.
1. réteg: Vásárlás utáni átirányítások
A háromlépcsős megközelítésünk első rétege közvetlenül a felhasználó fizetésének befejezése után történik. Mind a Stripe, mind a PayPal biztosít mechanizmusokat arra, hogy a felhasználókat tranzakciós információkkal visszairányítsák a weboldalunkra.
Stripe Checkout megvalósítás
A Stripe esetében a Checkout Sessions API-jukat használjuk egy zökkenőmentes fizetési élmény létrehozásához. Amikor a felhasználó kiválaszt egy csomagot és hitelkártyával szeretne fizetni, létrehozunk egy Checkout Session-t konkrét siker és megszakítás URL-ekkel:
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 };
}
A kritikus rész itt a success_url paraméter, amely tartalmazza a session_id-t lekérdezési paraméterként. Amikor a Stripe sikeres fizetés után visszairányítja a felhasználót az oldalunkra, ezt a session ID-t használhatjuk a tranzakció ellenőrzésére és az adatbázisunk frissítésére.
PayPal fizetési folyamat
A PayPal esetében hasonló megközelítést alkalmazunk az Orders API-jukkal:
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'
}
]
}
]
};
Hasonlóan a Stripe-hoz, megadjuk a return_url és cancel_url paramétereket a fizetés utáni átirányítások kezelésére. Amikor a PayPal visszairányítja a felhasználót az oldalunkra, rögzíthetjük a fizetés részleteit és frissíthetjük az adatbázisunkat.
2. réteg: Webhook kezelők aláírás ellenőrzéssel
Bár a post-checkout átirányítások a legtöbb esetben jól működnek, nem tökéletesek. A felhasználók bezárhatják a böngészőt az átirányítás előtt, vagy hálózati problémák akadályozhatják az átirányítás befejezését. Itt jönnek képbe a webhookok.
Mind a Stripe, mind a PayPal webhook rendszert biztosít, amely valós idejű értesítéseket küld a fizetési eseményekről. Mi robusztus webhook kezelőket valósítottunk meg, amelyek ellenőrzik ezeknek az értesítéseknek a hitelességét, és ennek megfelelően dolgozzák fel őket.
Stripe webhook megvalósítás
A Stripe webhook kezelőnk ellenőrzi a bejövő webhook események aláírását, hogy megbizonyosodjon azok hitelességéről:
async function webhook(ctx) {
const sig = ctx.request.get('stripe-signature');
// dobjon hibát, ha valami nem stimmel
if (!isSANB(sig))
throw Boom.badRequest(ctx.translateError('INVALID_STRIPE_SIGNATURE'));
const event = stripe.webhooks.constructEvent(
ctx.request.rawBody,
sig,
env.STRIPE_ENDPOINT_SECRET
);
// dobjon hibát, ha valami nem stimmel
if (!event)
throw Boom.badRequest(ctx.translateError('INVALID_STRIPE_SIGNATURE'));
ctx.logger.info('stripe webhook', { event });
// válasz visszaküldése az esemény átvételének megerősítésére
ctx.body = { received: true };
// háttérben futtatás
processEvent(ctx, event)
.then()
.catch((err) => {
ctx.logger.fatal(err, { event });
// adminisztrátornak email hibaüzenet
emailHelper({
template: 'alert',
message: {
to: config.email.message.from,
subject: `Hiba a Stripe Webhook-kal (Esemény ID ${event.id})`
},
locals: {
message: `<pre><code>${safeStringify(
parseErr(err),
null,
2
)}</code></pre>`
}
})
.then()
.catch((err) => ctx.logger.fatal(err, { event }));
});
}
A stripe.webhooks.constructEvent függvény az aláírást az endpoint titkunk segítségével ellenőrzi. Ha az aláírás érvényes, az eseményt aszinkron módon dolgozzuk fel, hogy ne blokkoljuk a webhook válaszát.
PayPal webhook megvalósítás
Hasonlóan, a PayPal webhook kezelőnk is ellenőrzi a bejövő értesítések hitelességét:
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);
// dobjon hibát, ha valami nem stimmel
if (!_.isObject(response) || response.verification_status !== 'SUCCESS')
throw Boom.badRequest(ctx.translateError('INVALID_PAYPAL_SIGNATURE'));
// válasz visszaküldése az esemény átvételének megerősítésére
ctx.body = { received: true };
// háttérben futtatás
processEvent(ctx)
.then()
.catch((err) => {
ctx.logger.fatal(err);
// adminisztrátornak email hibaüzenet
emailHelper({
template: 'alert',
message: {
to: config.email.message.from,
subject: `Hiba a PayPal Webhook-kal (Esemény ID ${ctx.request.body.id})`
},
locals: {
message: `<pre><code>${safeStringify(
parseErr(err),
null,
2
)}</code></pre>`
}
})
.then()
.catch((err) => ctx.logger.fatal(err));
});
}
Mindkét webhook kezelő ugyanazt a mintát követi: ellenőrzi az aláírást, visszaigazolja az átvételt, és aszinkron módon dolgozza fel az eseményt. Ez biztosítja, hogy soha ne maradjunk le egy fizetési eseményről, még akkor sem, ha a post-checkout átirányítás sikertelen.
3. réteg: Automatikus feladatok Bree-vel
A háromlépcsős megközelítésünk utolsó rétege egy sor automatikus feladat, amelyek időszakosan ellenőrzik és egyeztetik a fizetési adatokat. A Bree-t, egy Node.js feladatütemezőt használjuk ezeknek a feladatoknak a rendszeres futtatására.
Előfizetés pontosság ellenőrző
Az egyik kulcsfontosságú feladatunk az előfizetés pontosság ellenőrző, amely biztosítja, hogy adatbázisunk pontosan tükrözze az előfizetés állapotát a Stripe-ban:
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
}
}
}
Ez a kód automatikusan kitiltja azokat a felhasználókat, akiknek több sikertelen terhelésük van és nincs ellenőrzött domainjük, ami erős csalási tevékenységre utal.
Vitakezelés
Amikor egy felhasználó vitat egy terhelést, automatikusan elfogadjuk az igényt és megfelelő intézkedéseket teszünk:
case 'CUSTOMER.DISPUTE.CREATED': {
// igény elfogadása
const agent = await paypalAgent();
await agent
.post(`/v1/customer/disputes/${body.resource.dispute_id}/accept-claim`)
.send({
note: 'Teljes visszatérítés az ügyfélnek.'
});
// Fizetés keresése az adatbázisunkban
const payment = await Payments.findOne({ $or });
if (!payment) throw new Error('A fizetés nem létezik');
const user = await Users.findById(payment.user);
if (!user) throw new Error('A felhasználó nem létezett az ügyfélhez');
// A felhasználó előfizetésének lemondása, ha van ilyen
if (isSANB(user[config.userFields.paypalSubscriptionID])) {
try {
const agent = await paypalAgent();
await agent.post(
`/v1/billing/subscriptions/${
user[config.userFields.paypalSubscriptionID]
}/cancel`
);
} catch (err) {
// Előfizetés lemondási hibák kezelése
}
}
}
Ez a megközelítés minimalizálja a viták üzletünkre gyakorolt hatását, miközben biztosítja a jó ügyfélélményt.
Kód újrafelhasználás: KISS és DRY elvek
Fizetési rendszerünkben követjük a KISS (Keep It Simple, Stupid - Tartsd egyszerűen, hülye) és DRY (Don't Repeat Yourself - Ne ismételd magad) elveket. Íme néhány példa:
-
Megosztott segédfüggvények: Újrafelhasználható segédfüggvényeket hoztunk létre gyakori feladatokra, mint a fizetések szinkronizálása és e-mailek küldése.
-
Konzisztens hibakezelés: Mind a Stripe, mind a PayPal webhook kezelők ugyanazt a mintát használják a hibakezelésre és admin értesítésekre.
-
Egységes adatbázis séma: Adatbázis sémánk úgy van kialakítva, hogy mind a Stripe, mind a PayPal adatait kezelje, közös mezőkkel a fizetési státusz, összeg és előfizetési információk számára.
-
Központosított konfiguráció: A fizetéssel kapcsolatos konfiguráció egyetlen fájlban van központosítva, így könnyű az árak és termékinformációk frissítése.
graph TD subgraph "DRY Elv" V[Megosztott Logika] --> W[Fizetési Feldolgozó Funkciók] V --> X[Email Sablonok] V --> Y[Érvényesítési Logika]
Z[Általános Adatbázis Műveletek] --> AA[Felhasználói Frissítések]
Z --> AB[Fizetések Rögzítése]
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;
## VISA Előfizetési Követelmények Megvalósítása {#visa-subscription-requirements-implementation}
A hárompontos megközelítésünk mellett specifikus funkciókat valósítottunk meg a VISA előfizetési követelményeinek való megfelelés érdekében, miközben javítjuk a felhasználói élményt. Egy kulcsfontosságú követelmény a VISA részéről, hogy a felhasználókat értesíteni kell, mielőtt előfizetésükért díjat számítanak fel, különösen a próbaidőszakról fizetős előfizetésre való átálláskor.
### Automatikus Megújítás Előtti Email Értesítések {#automated-pre-renewal-email-notifications}
Létrehoztunk egy automatikus rendszert, amely azonosítja az aktív próba előfizetéssel rendelkező felhasználókat, és értesítő emailt küld nekik az első díj felszámítása előtt. Ez nemcsak a VISA követelményeknek való megfelelést biztosítja, hanem csökkenti a visszaterheléseket és javítja az ügyfél-elégedettséget.
Így valósítottuk meg ezt a funkciót:
```javascript
// Keressük azokat a felhasználókat, akiknek próba előfizetésük van és még nem kaptak értesítést
const users = await Users.find({
$or: [
{
$and: [
{ [config.userFields.stripeSubscriptionID]: { $exists: true } },
{ [config.userFields.stripeTrialSentAt]: { $exists: false } },
// Kizárjuk azokat az előfizetéseket, amelyeknél már volt fizetés
...(paidStripeSubscriptionIds.length > 0
? [
{
[config.userFields.stripeSubscriptionID]: {
$nin: paidStripeSubscriptionIds
}
}
]
: [])
]
},
{
$and: [
{ [config.userFields.paypalSubscriptionID]: { $exists: true } },
{ [config.userFields.paypalTrialSentAt]: { $exists: false } },
// Kizárjuk azokat az előfizetéseket, amelyeknél már volt fizetés
...(paidPayPalSubscriptionIds.length > 0
? [
{
[config.userFields.paypalSubscriptionID]: {
$nin: paidPayPalSubscriptionIds
}
}
]
: [])
]
}
]
});
// Feldolgozzuk az egyes felhasználókat és elküldjük az értesítést
for (const user of users) {
// Lekérjük az előfizetés részleteit a fizetési szolgáltatótól
const subscription = await getSubscriptionDetails(user);
// Kiszámoljuk az előfizetés időtartamát és gyakoriságát
const duration = getDurationFromPlanId(subscription.plan_id);
const frequency = getHumanReadableFrequency(duration, user.locale);
const amount = getPlanAmount(user.plan, duration);
// Lekérjük a felhasználó domainjeit a személyre szabott emailhez
const domains = await Domains.find({
'members.user': user._id
}).sort('name').lean().exec();
// Küldjük a VISA-kompatibilis értesítő emailt
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
}
});
// Rögzítjük, hogy az értesítés elküldésre került
await Users.findByIdAndUpdate(user._id, {
$set: {
[config.userFields.paypalTrialSentAt]: new Date()
}
});
}
Ez a megvalósítás biztosítja, hogy a felhasználók mindig értesüljenek a közelgő díjakról, világos részletekkel arról, hogy:
- Mikor történik az első díj felszámítása
- Milyen gyakorisággal lesznek a további díjak (havonta, évente, stb.)
- Pontosan mekkora összeget fognak felszámítani
- Mely domainek tartoznak az előfizetésükhöz
Az automatizált folyamat révén tökéletesen megfelelünk a VISA követelményeinek (amely előírja az értesítést legalább 7 nappal a díj felszámítása előtt), miközben csökkentjük a támogatási megkereséseket és javítjuk az általános felhasználói élményt.
Kezelési szélsőséges esetek
Megvalósításunk tartalmaz egy robusztus hibakezelést is. Ha bármi probléma adódik az értesítési folyamat során, rendszerünk automatikusan értesíti a csapatunkat:
try {
await mapper(user);
} catch (err) {
logger.error(err);
// Értesítés küldése az adminisztrátoroknak
await emailHelper({
template: 'alert',
message: {
to: config.email.message.from,
subject: 'VISA próba előfizetési követelmény hiba'
},
locals: {
message: `<pre><code>${safeStringify(
parseErr(err),
null,
2
)}</code></pre>`
}
});
}
Ez biztosítja, hogy még ha probléma is adódik az értesítési rendszerrel, csapatunk gyorsan tud reagálni és fenntartani a VISA követelményeinek való megfelelést.
A VISA előfizetés értesítési rendszere egy másik példa arra, hogyan építettük fel fizetési infrastruktúránkat egyszerre megfelelőség és felhasználói élmény szem előtt tartásával, kiegészítve trifecta megközelítésünket a megbízható, átlátható fizetési feldolgozás biztosítása érdekében.
Próbaidőszakok és előfizetési feltételek
Azoknak a felhasználóknak, akik meglévő csomagokon engedélyezik az automatikus megújítást, kiszámoljuk a megfelelő próbaidőszakot, hogy biztosítsuk, ne számoljunk fel díjat, amíg a jelenlegi csomagjuk le nem jár:
if (
isEnableAutoRenew &&
dayjs(ctx.state.user[config.userFields.planExpiresAt]).isAfter(
dayjs()
)
) {
const hours = dayjs(
ctx.state.user[config.userFields.planExpiresAt]
).diff(dayjs(), 'hours');
// Próbaidőszak számításának kezelése
}
Továbbá világos információkat nyújtunk az előfizetési feltételekről, beleértve a számlázási gyakoriságot és a lemondási szabályzatokat, valamint részletes metaadatokat mellékelünk minden előfizetéshez a megfelelő nyomon követés és kezelés érdekében.
Összegzés: Trifecta megközelítésünk előnyei
Fizetési feldolgozásunk trifecta megközelítése számos kulcsfontosságú előnyt biztosított:
-
Megbízhatóság: Három rétegű fizetésellenőrzés bevezetésével biztosítjuk, hogy egyetlen fizetés se maradjon ki vagy legyen helytelenül feldolgozva.
-
Pontosság: Adatbázisunk mindig tükrözi az előfizetések és fizetések valós állapotát mind a Stripe-ban, mind a PayPal-ban.
-
Rugalmasság: A felhasználók választhatják a preferált fizetési módjukat anélkül, hogy a rendszer megbízhatósága csorbulna.
-
Robusztusság: Rendszerünk szélsőséges eseteket is elegánsan kezeli, a hálózati hibáktól a csalárd tevékenységekig.
Ha több fizetési feldolgozót támogató rendszert valósítasz meg, erősen ajánljuk ezt a trifecta megközelítést. Több kezdeti fejlesztési erőfeszítést igényel, de a hosszú távú előnyök a megbízhatóság és pontosság terén megérik.
További információkért a Forward Email-ről és adatvédelmi fókuszú e-mail szolgáltatásainkról látogass el a weboldalunkra.