Kuinka rakensimme vankan maksujärjestelmän Stripen ja PayPalin avulla: Trifecta-lähestymistapa

Esipuhe
Forward Emaililla olemme aina priorisoineet luotettavien, tarkkojen ja käyttäjäystävällisten järjestelmien luomista. Kun maksujärjestelmämme käyttöönotto tuli ajankohtaiseksi, tiesimme tarvitsevamme ratkaisun, joka pystyy käsittelemään useita maksunvälittäjiä ja samalla ylläpitämään täydellisen datan yhtenäisyyden. Tässä blogikirjoituksessa kerrotaan, kuinka kehitystiimimme integroi sekä Stripen että PayPalin käyttämällä trifecta-lähestymistapaa, joka varmistaa reaaliaikaisen 1:1-tarkkuuden koko järjestelmässämme.
Haaste: Useita maksupalveluntarjoajia, yksi totuuden lähde
Yksityisyyteen keskittyvänä sähköpostipalveluna halusimme tarjota käyttäjillemme maksuvaihtoehtoja. Jotkut pitävät parempana Stripen kautta tehtävien luottokorttimaksujen yksinkertaisuutta, kun taas toiset arvostavat PayPalin tarjoamaa lisäerottelukerrosta. Useiden maksupalveluntarjoajien tukeminen tuo kuitenkin mukanaan merkittävää monimutkaisuutta:
-
Miten varmistamme yhdenmukaisen datan eri maksujärjestelmien välillä?
-
Miten käsittelemme ääritapauksia, kuten riitoja, hyvityksiä tai epäonnistuneita maksuja?
-
Miten ylläpidämme yhtä totuuden lähdettä tietokannassamme?
Ratkaisumme oli toteuttaa niin sanottu "trifecta-lähestymistapa" – kolmikerroksinen järjestelmä, joka tarjoaa redundanssia ja varmistaa datan yhtenäisyyden tapahtumista riippumatta.
Trifecta-lähestymistapa: Kolme luotettavuuden tasoa
Maksujärjestelmämme koostuu kolmesta kriittisestä komponentista, jotka toimivat yhdessä varmistaakseen täydellisen datan synkronoinnin:
- Maksun jälkeiset uudelleenohjaukset - Maksutietojen kerääminen heti kassan jälkeen
- Webhook-käsittelijät - Maksujen käsittelijöiden reaaliaikaisten tapahtumien käsittely
- Automatisoidut työt - Maksutietojen säännöllinen tarkistaminen ja täsmäyttäminen
Sukelletaanpa jokaiseen komponenttiin ja katsotaan, miten ne toimivat yhdessä.
Kerros 1: Maksun jälkeiset uudelleenohjaukset
Trifecta-lähestymistapamme ensimmäinen taso tapahtuu heti, kun käyttäjä on suorittanut maksun. Sekä Stripe että PayPal tarjoavat mekanismeja, jotka ohjaavat käyttäjät takaisin sivustollemme maksutietojen kanssa.
Stripe Checkout -toteutus
Stripen osalta käytämme heidän Checkout Sessions -rajapintaansa luodaksemme saumattoman maksukokemuksen. Kun käyttäjä valitsee tilauksen ja maksaa luottokortilla, luomme Checkout Sessionin, jolla on tietty onnistuminen, ja peruutamme URL-osoitteet:
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 };
}
Kriittinen osa tässä on success_url
-parametri, joka sisältää session_id
-parametrin kyselyparametrina. Kun Stripe ohjaa käyttäjän takaisin sivustollemme onnistuneen maksun jälkeen, voimme käyttää tätä istuntotunnusta tapahtuman tarkistamiseen ja tietokantaamme päivittämiseen vastaavasti.
PayPal-maksuprosessi
PayPalin kohdalla käytämme samanlaista lähestymistapaa heidän Orders API:nsa kanssa:
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'
}
]
}
]
};
Samoin kuin Stripessä, määritämme return_url
- ja cancel_url
-parametrit maksun jälkeisten uudelleenohjausten käsittelemiseksi. Kun PayPal ohjaa käyttäjän takaisin sivustollemme, voimme tallentaa maksutiedot ja päivittää tietokantaamme.
Kerros 2: Webhook-käsittelijät allekirjoituksen varmentamiseen
Vaikka kassan jälkeiset uudelleenohjaukset toimivat hyvin useimmissa tilanteissa, ne eivät ole erehtymättömiä. Käyttäjät saattavat sulkea selaimensa ennen uudelleenohjausta, tai verkko-ongelmat voivat estää uudelleenohjauksen suorittamisen. Tässä webhookit tulevat mukaan kuvaan.
Sekä Stripe että PayPal tarjoavat webhook-järjestelmiä, jotka lähettävät reaaliaikaisia ilmoituksia maksutapahtumista. Olemme ottaneet käyttöön vankat webhook-käsittelijät, jotka tarkistavat näiden ilmoitusten aitouden ja käsittelevät ne asianmukaisesti.
Stripe-webhookin toteutus
Stripe-webhook-käsittelijämme tarkistaa saapuvien webhook-tapahtumien allekirjoituksen varmistaakseen, että ne ovat aitoja:
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
-funktio tarkistaa allekirjoituksen päätepisteen salaisuutemme avulla. Jos allekirjoitus on kelvollinen, käsittelemme tapahtuman asynkronisesti, jotta webhook-vastaus ei estyisi.
PayPal Webhook -toteutus
Samoin PayPal-webhook-käsittelijämme tarkistaa saapuvien ilmoitusten aitouden:
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));
});
}
Molemmat webhook-käsittelijät noudattavat samaa kaavaa: tarkistavat allekirjoituksen, kuittaavat vastaanoton ja käsittelevät tapahtuman asynkronisesti. Tämä varmistaa, että emme koskaan unohda maksutapahtumaa, vaikka kassan jälkeinen uudelleenohjaus epäonnistuisi.
Kerros 3: Automatisoidut työt Breen avulla
Trifecta-lähestymistapamme viimeinen taso on joukko automatisoituja töitä, jotka säännöllisesti tarkistavat ja täsmäävät maksutiedot. Käytämme Bree-työaikataulutustyökalua Node.js:lle näiden töiden suorittamiseen säännöllisin väliajoin.
Tilauksen tarkkuuden tarkistin
Yksi keskeisistä tehtävistämme on tilausten tarkkuuden tarkistus, joka varmistaa, että tietokanta heijastaa tarkasti tilausten tilaa Stripessä:
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)
)}`
}
});
}
}
Tämä työ tarkistaa tietokantaamme ja Stripen välillä olevat ristiriidat, kuten yhteensopimattomat sähköpostiosoitteet tai useat aktiiviset tilaukset. Jos se löytää ongelmia, se kirjaa ne ja lähettää hälytykset ylläpitäjillemme.
PayPal-tilauksen synkronointi
Meillä on samanlainen työ PayPal-tilauksille:
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...
);
}
Nämä automatisoidut työt toimivat viimeisenä turvaverkkonamme, joka varmistaa, että tietokanta heijastaa aina tilausten ja maksujen todellista tilaa sekä Stripessä että PayPalissa.
Reunatapausten käsittely
Vankan maksujärjestelmän on käsiteltävä reunatapaukset sujuvasti. Katsotaanpa, miten käsittelemme joitakin yleisiä tilanteita.
Petosten havaitseminen ja ehkäisy
Olemme ottaneet käyttöön kehittyneitä petosten havaitsemismekanismeja, jotka tunnistavat ja käsittelevät automaattisesti epäilyttävät maksutapahtumat:
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
}
}
}
Tämä koodi estää automaattisesti käyttäjät, joilla on useita epäonnistuneita veloituksia ja joilla ei ole vahvistettuja verkkotunnuksia, mikä on vahva osoitus vilpillisestä toiminnasta.
Riitautusten käsittely
Kun käyttäjä kiistää veloituksen, hyväksymme vaatimuksen automaattisesti ja ryhdymme asianmukaisiin toimiin:
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
}
}
}
Tämä lähestymistapa minimoi riitojen vaikutukset liiketoimintaamme ja varmistaa samalla hyvän asiakaskokemuksen.
Koodin uudelleenkäyttö: KISS- ja DRY-periaatteet
Maksujärjestelmässämme olemme noudattaneet KISS- (Keep It Simple, Stupid) ja DRY- (Don't Repeat Yourself) periaatteita. Tässä on joitakin esimerkkejä:
-
Jaetut apufunktiot: Olemme luoneet uudelleenkäytettäviä apufunktioita yleisiin tehtäviin, kuten maksujen synkronointiin ja sähköpostien lähettämiseen.
-
Johdonmukainen virheiden käsittely: Sekä Stripen että PayPalin webhook-käsittelijät käyttävät samaa mallia virheiden käsittelyyn ja järjestelmänvalvojan ilmoituksiin.
-
Yhtenäinen tietokantarakenne: Tietokantarakenne on suunniteltu sekä Stripen että PayPalin datan käsittelyyn, ja siinä on yhteiset kentät maksun tilalle, summalle ja sopimustiedoille.
-
Keskitetty konfigurointi: Maksuihin liittyvä konfigurointi on keskitetty yhteen tiedostoon, mikä helpottaa hinnoittelun ja tuotetietojen päivittämistä.
VISA-tilausvaatimusten käyttöönotto
Trifecta-lähestymistapamme lisäksi olemme ottaneet käyttöön erityisominaisuuksia, jotka täyttävät VISAn tilausvaatimukset ja parantavat samalla käyttökokemusta. Yksi VISAn keskeisistä vaatimuksista on, että käyttäjille on ilmoitettava ennen tilauksen veloittamista, erityisesti siirryttäessä kokeilujaksosta maksulliseen tilaukseen.
Automaattiset uusimista edeltävät sähköposti-ilmoitukset
Olemme rakentaneet automatisoidun järjestelmän, joka tunnistaa aktiivisen kokeilujakson omaavat käyttäjät ja lähettää heille ilmoitussähköpostin ennen ensimmäistä veloitusta. Tämä paitsi pitää meidät VISA-vaatimusten mukaisina, myös vähentää takaisinperintöjä ja parantaa asiakastyytyväisyyttä.
Näin toteutimme tämän ominaisuuden:
// 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()
}
});
}
Tämä toteutus varmistaa, että käyttäjät ovat aina tietoisia tulevista veloituksista ja että heillä on selkeät tiedot seuraavista asioista:
- Milloin ensimmäinen veloitus tapahtuu
- Tulevien veloitusten tiheys (kuukausittain, vuosittain jne.)
- Tarkka veloitettava summa
- Mitkä verkkotunnukset tilaukseen kuuluvat
Automatisoimalla tämän prosessin ylläpidämme täydellistä VISA-vaatimusten noudattamista (jotka edellyttävät ilmoitusta vähintään 7 päivää ennen veloitusta) samalla vähentäen tukikyselyjä ja parantaen yleistä käyttäjäkokemusta.
Reunatapausten käsittely
Toteutukseemme kuuluu myös vankka virheenkäsittely. Jos ilmoitusprosessin aikana ilmenee ongelmia, järjestelmämme ilmoittaa siitä automaattisesti tiimillemme:
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>`
}
});
}
Tämä varmistaa, että vaikka ilmoitusjärjestelmässä olisi ongelmia, tiimimme voi korjata ne nopeasti ja ylläpitää VISAn vaatimustenmukaisuutta.
VISA-tilausilmoitusjärjestelmä on jälleen yksi esimerkki siitä, miten olemme rakentaneet maksuinfrastruktuurimme sekä vaatimustenmukaisuus että käyttäjäkokemus mielessä pitäen. Se täydentää trifecta-lähestymistapaamme luotettavan ja läpinäkyvän maksujen käsittelyn varmistamiseksi.
Kokeilujaksot ja tilausehdot
Käyttäjille, jotka ottavat käyttöön automaattisen uusimisen olemassa olevissa paketeissa, laskemme sopivan kokeilujakson varmistaaksemme, ettei heitä veloiteta ennen nykyisen paketin vanhenemista:
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
}
Tarjoamme myös selkeät tiedot tilausehdoista, mukaan lukien laskutustiheys ja peruutusehdot, ja sisällytämme jokaisen tilauksen mukana yksityiskohtaiset metatiedot asianmukaisen seurannan ja hallinnan varmistamiseksi.
Yhteenveto: Trifecta-lähestymistapamme edut
Kolminkertainen lähestymistapamme maksujen käsittelyyn on tuonut useita keskeisiä etuja:
-
Luotettavuus: Toteuttamalla kolmikerroksisen maksunvahvistuksen varmistamme, ettei yhtäkään maksua jää huomaamatta tai käsitellä väärin.
-
Tarkkuus: Tietokantaamme heijastaa aina tilausten ja maksujen todellista tilaa sekä Stripessä että PayPalissa.
-
Joustavuus: Käyttäjät voivat valita haluamansa maksutavan vaarantamatta järjestelmämme luotettavuutta.
-
Lujuus: Järjestelmämme käsittelee reunatapaukset sujuvasti verkkohäiriöistä petolliseen toimintaan.
Jos olet toteuttamassa maksujärjestelmää, joka tukee useita prosessoreita, suosittelemme tätä trifecta-lähestymistapaa. Se vaatii enemmän alkuvaiheen kehitystyötä, mutta pitkän aikavälin hyödyt luotettavuuden ja tarkkuuden suhteen ovat sen arvoisia.
Lisätietoja sähköpostin edelleenlähetyksestä ja yksityisyyttä suojaavista sähköpostipalveluistamme on osoitteessa verkkosivusto.