StripeとPayPalで堅牢な決済システムを構築した方法:トリフェクタアプローチ
序文
Forward Emailでは、常に信頼性が高く、正確で、ユーザーフレンドリーなシステムの構築を最優先してきました。決済処理システムの実装にあたっては、複数の決済プロセッサーを扱いながらも完璧なデータ整合性を維持できるソリューションが必要だと認識していました。本記事では、当社の開発チームがStripeとPayPalの両方を統合し、システム全体で1:1のリアルタイム精度を保証するトリフェクタアプローチをどのように実装したかを詳述します。
課題:複数の決済プロセッサー、唯一の真実の情報源
プライバシー重視のメールサービスとして、ユーザーに複数の決済オプションを提供したいと考えました。Stripeによるクレジットカード決済のシンプルさを好む方もいれば、PayPalが提供する追加の分離レイヤーを評価する方もいます。しかし、複数の決済プロセッサーをサポートすることは大きな複雑さを伴います:
- 異なる決済システム間でデータの一貫性をどう確保するか?
- 紛争、返金、支払い失敗などのエッジケースをどう処理するか?
- データベース内で唯一の真実の情報源をどう維持するか?
私たちの解決策は「トリフェクタアプローチ」と呼ぶ、冗長性を持ち、何が起きてもデータ整合性を保証する3層構造のシステムを実装することでした。
トリフェクタアプローチ:信頼性の3層構造
当社の決済システムは、完璧なデータ同期を保証するために連携する3つの重要なコンポーネントで構成されています:
- チェックアウト後のリダイレクト - チェックアウト直後に決済情報を取得
- Webhookハンドラー - 決済プロセッサーからのリアルタイムイベントを処理
- 自動ジョブ - 定期的に決済データを検証・照合
それぞれのコンポーネントがどのように連携しているか見ていきましょう。
レイヤー1: チェックアウト後のリダイレクト
私たちの三段構えアプローチの最初のレイヤーは、ユーザーが支払いを完了した直後に発生します。Stripe と PayPal の両方が、取引情報を含めてユーザーを当サイトにリダイレクトする仕組みを提供しています。
Stripe チェックアウトの実装
Stripe では、Checkout Sessions API を使用してシームレスな支払い体験を作成しています。ユーザーがプランを選択しクレジットカードで支払う場合、特定の成功およびキャンセル URL を指定して Checkout Session を作成します:
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
};
// チェックアウトセッションを作成しリダイレクト
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 が支払い成功後にユーザーを当サイトにリダイレクトするとき、このセッション ID を使って取引を検証し、データベースを更新できます。
PayPal 支払いフロー
PayPal では、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'
}
]
}
]
};
Stripe と同様に、支払い後のリダイレクトを処理するために return_url と cancel_url パラメータを指定しています。PayPal がユーザーを当サイトにリダイレクトするときに支払い詳細を取得し、データベースを更新できます。
Layer 2: 署名検証付きWebhookハンドラー
ポストチェックアウトのリダイレクトはほとんどのシナリオでうまく機能しますが、完全ではありません。ユーザーがリダイレクト前にブラウザを閉じたり、ネットワークの問題でリダイレクトが完了しないことがあります。そこでWebhookが役立ちます。
StripeとPayPalの両方が、支払いイベントに関するリアルタイム通知を送信するWebhookシステムを提供しています。私たちはこれらの通知の真正性を検証し、適切に処理する堅牢なWebhookハンドラーを実装しています。
Stripe Webhook 実装
私たちのStripe webhookハンドラーは、受信したWebhookイベントの署名を検証して正当性を確認します:
async function webhook(ctx) {
const sig = ctx.request.get('stripe-signature');
// 問題があればエラーを投げる
if (!isSANB(sig))
throw Boom.badRequest(ctx.translateError('INVALID_STRIPE_SIGNATURE'));
const event = stripe.webhooks.constructEvent(
ctx.request.rawBody,
sig,
env.STRIPE_ENDPOINT_SECRET
);
// 問題があればエラーを投げる
if (!event)
throw Boom.badRequest(ctx.translateError('INVALID_STRIPE_SIGNATURE'));
ctx.logger.info('stripe webhook', { event });
// イベント受領を認識するレスポンスを返す
ctx.body = { received: true };
// バックグラウンドで実行
processEvent(ctx, event)
.then()
.catch((err) => {
ctx.logger.fatal(err, { event });
// 管理者にエラーをメール送信
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 関数は、エンドポイントシークレットを使って署名を検証します。署名が有効な場合、Webhookレスポンスのブロックを避けるためにイベントを非同期で処理します。
PayPal Webhook 実装
同様に、私たちのPayPal webhookハンドラーは受信通知の真正性を検証します:
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);
// 問題があればエラーを投げる
if (!_.isObject(response) || response.verification_status !== 'SUCCESS')
throw Boom.badRequest(ctx.translateError('INVALID_PAYPAL_SIGNATURE'));
// イベント受領を認識するレスポンスを返す
ctx.body = { received: true };
// バックグラウンドで実行
processEvent(ctx)
.then()
.catch((err) => {
ctx.logger.fatal(err);
// 管理者にエラーをメール送信
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));
});
}
両方のWebhookハンドラーは同じパターンに従っています:署名を検証し、受領を認識し、イベントを非同期で処理します。これにより、ポストチェックアウトのリダイレクトが失敗しても支払いイベントを見逃すことがありません。
Layer 3: Breeによる自動ジョブ
私たちの三段階アプローチの最終層は、定期的に支払いデータを検証・照合する自動ジョブのセットです。Node.js用のジョブスケジューラーであるBreeを使って、これらのジョブを定期的に実行しています。
サブスクリプション精度チェッカー
私たちの主要なジョブの一つはサブスクリプション精度チェッカーで、データベースが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
}
}
}
このコードは、複数回の決済失敗があり、かつ検証済みドメインがないユーザーを自動的に禁止します。これは不正行為の強い指標です。
Dispute 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: '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
}
}
}
この方法により、異議申し立てがビジネスに与える影響を最小限に抑えつつ、良好な顧客体験を確保しています。
Code Reuse: KISS and DRY Principles
私たちの決済システム全体で、KISS(Keep It Simple, Stupid)およびDRY(Don't Repeat Yourself)の原則を遵守しています。以下はいくつかの例です:
-
共有ヘルパー関数:決済同期やメール送信などの共通タスクのために再利用可能なヘルパー関数を作成しています。
-
一貫したエラーハンドリング:StripeとPayPalの両方のWebhookハンドラーは、同じパターンでエラーハンドリングと管理者通知を行います。
-
統一されたデータベーススキーマ:決済ステータス、金額、プラン情報などの共通フィールドを持つ、StripeとPayPal両方のデータに対応したスキーマ設計です。
-
集中管理された設定:決済関連の設定は単一ファイルに集中管理されており、価格や製品情報の更新が容易です。
graph TD subgraph "DRY Principle" V[共有ロジック] --> W[支払い処理関数] V --> X[メールテンプレート] V --> Y[検証ロジック]
Z[共通データベース操作] --> AA[ユーザー更新]
Z --> AB[支払い記録]
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サブスクリプション要件の実装 {#visa-subscription-requirements-implementation}
私たちのトリフェクタアプローチに加え、VISAのサブスクリプション要件に準拠しつつユーザー体験を向上させるための特定の機能を実装しました。VISAからの重要な要件の一つは、特にトライアルから有料サブスクリプションに移行する際に、ユーザーが課金される前に通知を受け取る必要があるということです。
### 自動化された事前更新メール通知 {#automated-pre-renewal-email-notifications}
アクティブなトライアルサブスクリプションを持つユーザーを特定し、最初の課金が行われる前に通知メールを送信する自動システムを構築しました。これにより、VISAの要件を満たすだけでなく、チャージバックの減少や顧客満足度の向上にもつながっています。
この機能の実装方法は以下の通りです:
```javascript
// 通知をまだ受け取っていないトライアルサブスクリプションのユーザーを検索
const users = await Users.find({
$or: [
{
$and: [
{ [config.userFields.stripeSubscriptionID]: { $exists: true } },
{ [config.userFields.stripeTrialSentAt]: { $exists: false } },
// すでに支払いが発生しているサブスクリプションは除外
...(paidStripeSubscriptionIds.length > 0
? [
{
[config.userFields.stripeSubscriptionID]: {
$nin: paidStripeSubscriptionIds
}
}
]
: [])
]
},
{
$and: [
{ [config.userFields.paypalSubscriptionID]: { $exists: true } },
{ [config.userFields.paypalTrialSentAt]: { $exists: false } },
// すでに支払いが発生しているサブスクリプションは除外
...(paidPayPalSubscriptionIds.length > 0
? [
{
[config.userFields.paypalSubscriptionID]: {
$nin: paidPayPalSubscriptionIds
}
}
]
: [])
]
}
]
});
// 各ユーザーを処理し通知を送信
for (const user of users) {
// 支払いプロセッサーからサブスクリプション詳細を取得
const subscription = await getSubscriptionDetails(user);
// サブスクリプションの期間と頻度を計算
const duration = getDurationFromPlanId(subscription.plan_id);
const frequency = getHumanReadableFrequency(duration, user.locale);
const amount = getPlanAmount(user.plan, duration);
// パーソナライズされたメールのためにユーザーのドメインを取得
const domains = await Domains.find({
'members.user': user._id
}).sort('name').lean().exec();
// 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
}
});
// 通知送信済みを記録
await Users.findByIdAndUpdate(user._id, {
$set: {
[config.userFields.paypalTrialSentAt]: new Date()
}
});
}
この実装により、ユーザーは常に以下の明確な詳細とともに今後の課金について通知されます:
- 最初の課金がいつ行われるか
- 今後の課金頻度(月次、年次など)
- 課金される正確な金額
- サブスクリプションでカバーされるドメイン
このプロセスを自動化することで、VISAの要件(課金の少なくとも7日前に通知すること)を完全に遵守しつつ、サポートへの問い合わせを減らし、全体的なユーザー体験を向上させています。
エッジケースの処理
当社の実装には堅牢なエラーハンドリングも含まれています。通知プロセス中に何か問題が発生した場合、システムは自動的にチームにアラートを送信します:
try {
await mapper(user);
} catch (err) {
logger.error(err);
// 管理者へのアラート送信
await emailHelper({
template: 'alert',
message: {
to: config.email.message.from,
subject: 'VISAトライアルサブスクリプション要件エラー'
},
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');
// トライアル期間の計算処理
}
また、請求頻度やキャンセルポリシーなどのサブスクリプション条件を明確に提供し、各サブスクリプションに詳細なメタデータを含めることで適切な追跡と管理を保証しています。
結論:三位一体アプローチの利点
当社の支払い処理における三位一体アプローチは、以下の主要な利点をもたらしています:
-
信頼性:3層の支払い検証を実装することで、支払いの見落としや誤処理を防止しています。
-
正確性:データベースは常にStripeとPayPalの両方のサブスクリプションおよび支払いの正確な状態を反映しています。
-
柔軟性:ユーザーはシステムの信頼性を損なうことなく、好みの支払い方法を選択できます。
-
堅牢性:ネットワーク障害から不正行為まで、エッジケースを適切に処理します。
複数の決済プロセッサをサポートする支払いシステムを実装する場合、この三位一体アプローチを強く推奨します。初期の開発コストはかかりますが、信頼性と正確性の面で長期的なメリットは非常に大きいです。
Forward Emailおよびプライバシー重視のメールサービスの詳細については、当社のウェブサイトをご覧ください。