Як ми створили надійну платіжну систему за допомогою Stripe та PayPal: Потрійний підхід

Передмова
У Forward Email ми завжди надавали пріоритет створенню надійних, точних та зручних у використанні систем. Коли справа дійшла до впровадження нашої системи обробки платежів, ми знали, що нам потрібне рішення, яке могло б обробляти кілька платіжних процесорів, зберігаючи при цьому ідеальну узгодженість даних. У цій публікації блогу детально описано, як наша команда розробників інтегрувала Stripe та PayPal, використовуючи потрійний підхід, який забезпечує точність 1:1 у режимі реального часу в усій нашій системі.
Виклик: Кілька платіжних систем, одне джерело достовірної інформації
Як сервіс електронної пошти, орієнтований на конфіденційність, ми хотіли надати нашим користувачам варіанти оплати. Деякі віддають перевагу простоті платежів кредитними картками через Stripe, тоді як інші цінують додатковий рівень розділення, який надає PayPal. Однак підтримка кількох платіжних систем створює значні складнощі:
- Як ми забезпечуємо узгодженість даних у різних платіжних системах?
- Як ми вирішуємо граничні випадки, такі як суперечки, повернення коштів або невдалі платежі?
- Як ми підтримуємо єдине джерело достовірної інформації в нашій базі даних?
Нашим рішенням було впровадження того, що ми називаємо «потрійним підходом» – трирівневої системи, яка забезпечує резервування та гарантує узгодженість даних незалежно від обставин.
Підхід Trifecta: три рівні надійності
Наша платіжна система складається з трьох критично важливих компонентів, які працюють разом для забезпечення ідеальної синхронізації даних:
- Переадресації після оформлення замовлення – Збір інформації про оплату одразу після оформлення замовлення
- Обробники вебхуків – Обробка подій у режимі реального часу від платіжних процесорів
- Автоматизовані завдання – Періодична перевірка та узгодження даних про оплату
Давайте заглибимося в кожен компонент і подивимося, як вони працюють разом.
Рівень 1: Перенаправлення після оформлення замовлення
Перший рівень нашого потрійного підходу реалізується одразу після того, як користувач завершує платіж. Як Stripe, так і PayPal надають механізми для перенаправлення користувачів назад на наш сайт з інформацією про транзакцію.
Реалізація оформлення замовлення Stripe
Для Stripe ми використовуємо їхній API Checkout Sessions для створення безперебійного процесу оплати. Коли користувач вибирає план і вирішує оплатити кредитною карткою, ми створюємо сеанс оформлення замовлення з певними успішними результатами та скасовуємо URL-адреси:
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 };
}
Найважливішою частиною тут є параметр success_url
, який включає session_id
як параметр запиту. Коли Stripe перенаправляє користувача назад на наш сайт після успішної оплати, ми можемо використовувати цей ідентифікатор сеансу для перевірки транзакції та відповідного оновлення нашої бази даних.
Процес оплати PayPal
Для PayPal ми використовуємо аналогічний підхід з їхнім 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'
}
]
}
]
};
Подібно до Stripe, ми вказуємо параметри return_url
та cancel_url
для обробки переадресацій після оплати. Коли PayPal перенаправляє користувача назад на наш сайт, ми можемо зібрати платіжні дані та оновити нашу базу даних.
Рівень 2: Обробники вебхуків з перевіркою підпису
Хоча перенаправлення після оформлення замовлення добре працюють у більшості сценаріїв, вони не є надійними. Користувачі можуть закрити браузер перед перенаправленням, або проблеми з мережею можуть перешкодити завершенню перенаправлення. Ось тут і знадобляться вебхуки.
Як Stripe, так і PayPal пропонують системи вебхуків, які надсилають сповіщення про платежі в режимі реального часу. Ми реалізували надійні обробники вебхуків, які перевіряють справжність цих сповіщень та обробляють їх відповідно.
Реалізація вебхука Stripe
Наш обробник вебхуків Stripe перевіряє підпис вхідних подій вебхуків, щоб переконатися в їх легітимності:
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 }));
});
}
Функція stripe.webhooks.constructEvent
перевіряє підпис за допомогою нашого секретного коду кінцевої точки. Якщо підпис дійсний, ми обробляємо подію асинхронно, щоб уникнути блокування відповіді вебхука.
Реалізація вебхука PayPal
Аналогічно, наш обробник вебхуків PayPal перевіряє справжність вхідних сповіщень:
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));
});
}
Обидва обробники вебхуків дотримуються одного шаблону: перевіряють підпис, підтверджують отримання та обробляють подію асинхронно. Це гарантує, що ми ніколи не пропустимо подію оплати, навіть якщо перенаправлення після оформлення замовлення завершиться невдачею.
Рівень 3: Автоматизовані завдання з Bree
Останній рівень нашого потрійного підходу — це набір автоматизованих завдань, які періодично перевіряють та узгоджують дані про платежі. Ми використовуємо Bree, планувальник завдань для Node.js, для запуску цих завдань через регулярні проміжки часу.
Перевірка точності підписки
Одним з наших ключових завдань є перевірка точності підписок, яка гарантує, що наша база даних точно відображає стан підписок у 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)
)}`
}
});
}
}
Це завдання перевіряє наявність розбіжностей між нашою базою даних та Stripe, таких як невідповідні адреси електронної пошти або кілька активних підписок. Якщо виявляється будь-яка проблема, вона реєструє її та надсилає сповіщення нашій команді адміністраторів.
Синхронізація підписки PayPal
У нас є схожа робота для підписок PayPal:
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...
);
}
Ці автоматизовані завдання слугують нашою останньою системою безпеки, гарантуючи, що наша база даних завжди відображає справжній стан підписок і платежів як у Stripe, так і в PayPal.
Обробка граничних випадків
Надійна платіжна система повинна коректно обробляти пограничні випадки. Давайте розглянемо, як ми обробляємо деякі поширені сценарії.
Виявлення та запобігання шахрайству
Ми впровадили складні механізми виявлення шахрайства, які автоматично виявляють та обробляють підозрілі платежі:
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
}
}
}
Цей код автоматично блокує користувачів, у яких було кілька невдалих платежів та немає перевірених доменів, що є вагомим показником шахрайської діяльності.
Розгляд суперечок
Коли користувач оскаржує стягнення плати, ми автоматично приймаємо претензію та вживаємо відповідних заходів:
case 'CUSTOMER.DISPUTE.CREATED': {
// accept claim
const agent = await paypalAgent();
await agent
.post(`/v1/customer/disputes/${body.resource.dispute_id}/accept-claim`)
.send({
note: 'Full refund to the customer.'
});
// Find the payment in our database
const payment = await Payments.findOne({ $or });
if (!payment) throw new Error('Payment does not exist');
const user = await Users.findById(payment.user);
if (!user) throw new Error('User did not exist for customer');
// Cancel the user's subscription if they have one
if (isSANB(user[config.userFields.paypalSubscriptionID])) {
try {
const agent = await paypalAgent();
await agent.post(
`/v1/billing/subscriptions/${
user[config.userFields.paypalSubscriptionID]
}/cancel`
);
} catch (err) {
// Handle subscription cancellation errors
}
}
}
Такий підхід мінімізує вплив суперечок на наш бізнес, водночас забезпечуючи хороший клієнтський досвід.
Повторне використання коду: принципи KISS та DRY
У всій нашій платіжній системі ми дотримуємося принципів KISS (Keep It Simple, Stupid) та DRY (Don't Repeat Yourself). Ось кілька прикладів:
-
Спільні допоміжні функції: Ми створили допоміжні функції багаторазового використання для поширених завдань, таких як синхронізація платежів та надсилання електронних листів.
-
Узгоджена обробка помилок: Обробники вебхуків Stripe та PayPal використовують однаковий шаблон для обробки помилок та сповіщень адміністратора.
-
Уніфікована схема бази даних: Наша схема бази даних розроблена для врахування даних Stripe та PayPal, зі спільними полями для статусу платежу, суми та інформації про план.
-
Централізована конфігурація: конфігурація, пов’язана з оплатою, централізована в одному файлі, що спрощує оновлення цін та інформації про продукт.
Впровадження вимог до підписки VISA
Окрім нашого тривимірного підходу, ми впровадили спеціальні функції, щоб відповідати вимогам VISA щодо підписки, одночасно покращуючи взаємодію з користувачами. Одна з ключових вимог VISA полягає в тому, що користувачів необхідно повідомляти перед стягненням плати за підписку, особливо під час переходу з пробної версії на платну.
Автоматичні сповіщення електронною поштою про попереднє поновлення
Ми створили автоматизовану систему, яка ідентифікує користувачів з активними пробними підписками та надсилає їм сповіщення електронною поштою перед першим стягненням коштів. Це не лише забезпечує дотримання вимог VISA, але й зменшує кількість повернень платежів та підвищує задоволеність клієнтів.
Ось як ми реалізували цю функцію:
// 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()
}
});
}
Ця реалізація гарантує, що користувачі завжди будуть поінформовані про майбутні платежі, а також отримають чітку інформацію про:
- Коли відбудеться перше стягнення плати
- Частота майбутніх стягнень плати (щомісяця, щорічно тощо)
- Точна сума, яка з них стягуватиметься
- На які домени поширюється їхня підписка
Автоматизуючи цей процес, ми забезпечуємо повну відповідність вимогам VISA (які вимагають повідомлення щонайменше за 7 днів до стягнення плати), одночасно зменшуючи кількість запитів до служби підтримки та покращуючи загальний досвід користувачів.
Обробка граничних випадків
Наша реалізація також включає надійну обробку помилок. Якщо під час процесу сповіщення щось піде не так, наша система автоматично попередить нашу команду:
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>`
}
});
}
Це гарантує, що навіть якщо виникне проблема із системою сповіщень, наша команда зможе швидко її вирішити та забезпечити дотримання вимог VISA.
Система сповіщень про підписки VISA – це ще один приклад того, як ми побудували нашу платіжну інфраструктуру з урахуванням як відповідності вимогам, так і зручності користувачів, доповнюючи наш потрійний підхід до забезпечення надійної та прозорої обробки платежів.
Пробні періоди та умови підписки
Для користувачів, які ввімкнули автоматичне поновлення існуючих планів, ми розраховуємо відповідний пробний період, щоб гарантувати, що з них не стягуватиметься плата, доки не закінчиться термін дії їхнього поточного плану:
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
}
Ми також надаємо чітку інформацію про умови підписки, включаючи частоту виставлення рахунків та політику скасування, а також додаємо детальні метадані до кожної підписки для забезпечення належного відстеження та управління.
Висновок: Переваги нашого підходу Trifecta
Наш потрійний підхід до обробки платежів забезпечив кілька ключових переваг:
-
Надійність: Завдяки впровадженню трьох рівнів перевірки платежів ми гарантуємо, що жоден платіж не буде пропущено або неправильно оброблено.
-
Точність: Наша база даних завжди відображає справжній стан підписок і платежів як у Stripe, так і в PayPal.
-
Гнучкість: Користувачі можуть обрати бажаний спосіб оплати, не ставлячи під загрозу надійність нашої системи.
-
Надійність: Наша система плавно обробляє крайні випадки, від збоїв мережі до шахрайської діяльності.
Якщо ви впроваджуєте платіжну систему, яка підтримує кілька процесорів, ми наполегливо рекомендуємо цей потрійний підхід. Він вимагає більше початкових зусиль з розробки, але довгострокові переваги з точки зору надійності та точності того варті.
Щоб отримати докладнішу інформацію про пересилання електронної пошти та наші послуги електронної пошти, орієнтовані на конфіденційність, відвідайте нашу сторінку вебсайт.