Как мы создали надежную платежную систему с помощью Stripe и PayPal: тройной подход

Предисловие
В Forward Email мы всегда уделяли первостепенное внимание созданию надежных, точных и удобных для пользователя систем. Когда дело дошло до внедрения нашей системы обработки платежей, мы знали, что нам нужно решение, способное работать с несколькими платежными системами, сохраняя при этом идеальную согласованность данных. В этой записи блога подробно рассказывается о том, как наша команда разработчиков интегрировала Stripe и PayPal, используя комплексный подход, обеспечивающий точность 1:1 в режиме реального времени во всей нашей системе.
Задача: несколько платежных систем, один источник информации
Будучи сервисом электронной почты, ориентированным на конфиденциальность, мы хотели предоставить нашим пользователям различные варианты оплаты. Некоторые предпочитают простоту оплаты кредитными картами через Stripe, в то время как другие ценят дополнительный уровень безопасности, предоставляемый PayPal. Однако поддержка нескольких платежных систем значительно усложняет:
- Как мы обеспечиваем согласованность данных в разных платёжных системах?
- Как мы обрабатываем особые случаи, такие как споры, возвраты или невыполненные платежи?
- Как мы обеспечиваем единый источник достоверной информации в нашей базе данных?
Нашим решением стало внедрение так называемого «трифектного подхода» — трехуровневой системы, которая обеспечивает избыточность и гарантирует согласованность данных в любых ситуациях.
Подход «Трифекта»: три уровня надежности
Наша платежная система состоит из трех важнейших компонентов, которые работают вместе, обеспечивая идеальную синхронизацию данных:
- Переадресация после оформления заказа — сбор информации о платеже сразу после оформления заказа.
- Обработчики веб-перехватов — обработка событий от платежных систем в режиме реального времени.
- Автоматизированные задания — периодическая проверка и сверка платежных данных.
Давайте подробнее рассмотрим каждый компонент и посмотрим, как они работают вместе.
Уровень 1: Перенаправления после оформления заказа
Первый уровень нашего тройного подхода реализуется сразу после завершения пользователем платежа. И Stripe, и PayPal предоставляют механизмы перенаправления пользователей обратно на наш сайт с информацией о транзакции.
Реализация оформления заказов Stripe
Для Stripe мы используем API Checkout Sessions для создания бесперебойного процесса оплаты. Когда пользователь выбирает тарифный план и решает оплатить кредитной картой, мы создаём Checkout Session с определёнными 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 Webhook
Аналогичным образом наш обработчик веб-перехватов 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, планировщик заданий для 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
}
Мы также предоставляем четкую информацию об условиях подписки, включая частоту выставления счетов и правила отмены, а также включаем подробные метаданные в каждую подписку для обеспечения надлежащего отслеживания и управления.
Заключение: преимущества нашего тройного подхода
Наш тройной подход к обработке платежей обеспечил несколько ключевых преимуществ:
-
Надежность: Внедряя три уровня проверки платежей, мы гарантируем, что ни один платеж не будет пропущен или обработан неправильно.
-
Точность: Наша база данных всегда отражает истинное состояние подписок и платежей как в Stripe, так и в PayPal.
-
Гибкость: пользователи могут выбрать предпочитаемый ими способ оплаты, не ставя под угрозу надежность нашей системы.
-
Надежность: наша система эффективно обрабатывает пограничные случаи — от сетевых сбоев до мошеннических действий.
Если вы внедряете платёжную систему с поддержкой нескольких процессоров, мы настоятельно рекомендуем этот тройной подход. Он требует больших начальных усилий на разработку, но долгосрочные преимущества в плане надёжности и точности того стоят.
Дополнительную информацию о пересылке электронной почты и наших услугах электронной почты, ориентированных на конфиденциальность, можно найти на нашем сайте веб-сайт.