UNPKG

@liberquack/utils

Version:
375 lines 18.3 kB
import { Stripe } from "stripe"; import { AbstractPaymentServerProvider } from "./abstract-payment-server-provider.js"; import { logger } from "../../logger.js"; import { inlineErr } from "../../inline-error.js"; //Should reuse something from client provider export class PaymentStripeServerProvider extends AbstractPaymentServerProvider { opts; stripe; provider = "stripe"; constructor(apiKey, opts) { super(); this.opts = opts; this.stripe = new Stripe(apiKey, { apiVersion: "2020-08-27" }); } async readWebhookCustomer(event) { let stripeCustomerId; let eventData = event?.data?.object; let customer; if (event.account) { stripeCustomerId = event.account; } else if (eventData && eventData.customer) { stripeCustomerId = eventData.customer.id || eventData.customer; customer = typeof eventData.customer !== "string" && eventData.customer; } if (stripeCustomerId) { let customerObj = customer || await this.stripe.customers.retrieve(stripeCustomerId); const email = "email" in customerObj && customerObj.email; if (email) { return { email }; } } } async supportsWebhook(event) { switch (event?.type) { case "invoice.payment_succeeded": return true; default: return false; } } async readWebhookCheckout(user, event) { switch (event.type) { case 'invoice.payment_succeeded': const invoice = event.data.object; const subId = invoice.subscription?.id || invoice.subscription; if (subId) { return { providerCheckoutId: subId }; } default: console.log(this.readWebhookCheckout, `Unhandled event type ${event.type}`); } } async handleWebhook(user, checkout, webhookData) { let eventType = webhookData.type; switch (eventType) { case "invoice.payment_succeeded": const invoice = webhookData.data.object; const paymentIntent = await this.retrievePaymentIntent(invoice.payment_intent); const subscriptionId = invoice.subscription?.id || invoice.subscription; const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); return this.buildCheckoutResult(checkout, paymentIntent, subscription, [], checkout.items.map(it => it.product)); default: console.log(this.handleWebhook, `Stripe event ${eventType} ignored`); } } async retrievePaymentIntent(paymentIntent) { if (!paymentIntent) { throw "Empty payment intent"; } if (typeof paymentIntent === "string" && paymentIntent.startsWith("pi_")) { return this.stripe.paymentIntents.retrieve(paymentIntent); } if (typeof paymentIntent === "object" && paymentIntent.object === "payment_intent") { return paymentIntent; } throw "Unexpected object"; } async updateDefaultCard(user, cardIdentifier) { const stripeAccount = this._getStripeAccount(user); if (!stripeAccount) { throw `User ${user} doesn't have a stripe account`; } const response = await this.stripe.customers.update(stripeAccount.customer.id, { default_source: cardIdentifier }); const { lastResponse, ...customer } = response; return { ...stripeAccount, customer }; } async createCard(user, data) { const stripeAccount = this._getStripeAccount(user) || await this._createStripeAccount(user); const sourceListResponse = await this.stripe.customers.listSources(stripeAccount.customer.id, { object: 'card' }); const currentCardSources = sourceListResponse.data; const createdSource = await this.stripe.customers.createSource(stripeAccount.customer.id, { source: data.id }); const { lastResponse, ...customer } = await this.stripe.customers.retrieve(stripeAccount.customer.id); stripeAccount.customer = customer; stripeAccount.paymentSources = [ createdSource, ...currentCardSources ]; return stripeAccount; } async prepareCheckout(step, user, calculatedCheckout, opts) { if (step === "calc") { return { ...calculatedCheckout, provider: "stripe", externalData: { provider: "stripe", data: {} } }; } logger.info("stripe-server", "Preparing new checkout", opts ? "with custom payment intent params" : ""); const stripeAccount = await this.ensureStripeAccount(user); if (!stripeAccount) throw `Error, did not find a stripe account for user ${JSON.stringify(user)}`; const intent = await this.stripe.paymentIntents.create({ amount: Math.round(calculatedCheckout.total * 100), currency: calculatedCheckout.currency, customer: stripeAccount.customer.id, payment_method: opts?.paymentMethodId, ...opts?.intentOptions, capture_method: "manual", setup_future_usage: "off_session" }); const preparedCheckout = { ...calculatedCheckout, provider: "stripe", externalId: intent.id, externalData: { provider: "stripe", data: { ...calculatedCheckout.externalData?.data, intentStatus: intent.status, clientSecret: intent.client_secret, } } }; return preparedCheckout; } async checkout(user, checkoutObj) { logger.info("stripe-server", `Starting checkout for user ${checkoutObj.userId}`); if (!("externalData" in checkoutObj)) { throw "Expected externalData on checkout object"; } if ("errorMessage" in checkoutObj) { if (!checkoutObj.externalId) throw "Unexpected error"; const paymentIntent = await this.stripe.paymentIntents.cancel(checkoutObj.externalId); return this.buildCheckoutResult(checkoutObj, paymentIntent, undefined, [], []); } const onlyProducts = checkoutObj.items.map(it => it.type).every(it => it === "product"); if (!onlyProducts) { throw "Subscriptions are not supported"; //Reverts plan cancellation // let revertResult = await this.revertSubscriptionCancellation(user, checkoutObj); // if (revertResult) return revertResult; //TODO: Subscription logic should go to prepare method // const subscription = await this.createSubscription(checkoutObj, /*stripeAccount*/null as any); // if (subscription) return subscription } //If externalId is false, it's the first round trip if (checkoutObj.externalId === false) { let { paymentMethodToken, billingData } = checkoutObj.externalData.data; if (!billingData) throw "Expected billing data"; if (!paymentMethodToken) throw "Expected transaction payment method token"; if (!paymentMethodToken.card) throw "Expected token to be type card"; const paymentMethod = await this.stripe.paymentMethods.create({ type: "card", card: { token: paymentMethodToken.id }, billing_details: billingData }); if (!paymentMethod.card) throw "Expected card payment"; const customization = await this.opts?.customizeIntent?.(user, checkoutObj, paymentMethod.card); //TODO: prepareCheckout is creating payment intent, should extract it here checkoutObj = await this.prepareCheckout("execution", user, checkoutObj, { paymentMethodId: paymentMethod.id, intentOptions: { ...customization?.intentOpts, }, }); } if (!checkoutObj.externalId) throw "Intent id not found"; let intentNamespace = checkoutObj.externalId.startsWith("seti_") ? "setupIntents" : "paymentIntents"; //TODO: prepareCheckout is creating intent above, find some way to reuse that let intent = await this.stripe[intentNamespace].retrieve(checkoutObj.externalId, { expand: ["payment_method"] }); if (intent.status === "requires_confirmation") { intent = await this.stripe[intentNamespace].confirm(intent.id); } if (intent.status === "requires_action") { return this.buildCheckoutResult(checkoutObj, intent, undefined, [], []); } if (this.opts?.preCaptureCheck) { const [_, err] = await inlineErr(this.opts.preCaptureCheck(user, checkoutObj, intent)); if (err) { await this.stripe[intentNamespace].cancel(intent.id); throw err; } } if (intent.status === "requires_capture") { intent = await this.stripe.paymentIntents.capture(intent.id); } if (intent.status === "succeeded" && intent.object === "setup_intent") { let paymentIntent = await this.stripe.paymentIntents.create({ customer: intent.customer ? this.getIdFromStringOrObject(intent.customer) : undefined, payment_method: intent.payment_method ? this.getIdFromStringOrObject(intent.payment_method) : undefined, amount: Math.round(checkoutObj.total * 100), currency: checkoutObj.currency, confirm: true, capture_method: "manual", payment_method_options: intent.payment_method_options }); return this.buildCheckoutResult(checkoutObj, paymentIntent, undefined, [], checkoutObj.items.map(it => it.product)); } if (intent.status === "succeeded" && intent.object === "payment_intent") { return this.buildCheckoutResult(checkoutObj, intent, undefined, [], checkoutObj.items.map(it => it.product)); } throw "Unexpected error"; } async revertSubscriptionCancellation(user, checkoutObj) { const subscription = user.payment?.subscription; if (subscription) { const { nextBill, planningCancelDate } = subscription; const cancellationInFuture = nextBill.getTime() > new Date().getTime() && planningCancelDate; const checkoutProductIds = checkoutObj.items.map(it => it.productId); const sameLength = checkoutProductIds.length === subscription.productIds.length; const nextCheckoutSameItems = sameLength && checkoutProductIds.every(it => subscription.productIds.indexOf(it) > -1); if (cancellationInFuture && nextCheckoutSameItems) { const subscriptionResult = await this.stripe.subscriptions.update(subscription.externalId, { cancel_at_period_end: false, expand: ["latest_invoice", "latest_invoice.payment_intent"] }); const paymentIntent = await this.retrievePaymentIntent(subscriptionResult.latest_invoice); if (!paymentIntent) throw "Unexpected empty payment intent"; return this.buildCheckoutResult(checkoutObj, paymentIntent, subscriptionResult, [], checkoutObj.items.map(it => it.product)); } } } async cancelCheckout(user, checkoutObj) { const subscription = checkoutObj.externalData?.data?.subscription; if (!subscription?.id) throw "Expected field id on stripe subscription"; const subscriptionCanceled = await this.stripe.subscriptions.update(subscription.id, { cancel_at_period_end: true, expand: ["latest_invoice", "latest_invoice.payment_intent"] }); const paymentIntent = await this.retrievePaymentIntent(subscription.latest_invoice); return this.buildCheckoutResult(checkoutObj, paymentIntent, subscriptionCanceled, [], checkoutObj.items.map(it => it.product)); } async createSubscription(checkoutObj, stripeAccount) { const ensuredProducts = await this.ensureStripeProducts(checkoutObj.items.map(it => it.product)); const products = ensuredProducts.products; const items = await Promise.all(checkoutObj.items.map(async (checkoutItem) => { const product = products.find(it => it.getId() === checkoutItem.product.getId()); const item = { quantity: checkoutItem.quantity, price_data: { product: product.code, currency: checkoutItem.currency, unit_amount: Math.floor(checkoutItem.total * 100), recurring: { interval: "month", } } }; return item; })); const subscription = await this.stripe.subscriptions.create({ customer: stripeAccount.customer.id, items: items, expand: ["latest_invoice", "latest_invoice.payment_intent"] }); const paymentIntent = await this.retrievePaymentIntent(subscription.latest_invoice); const paymentProviderCheckoutResult = this.buildCheckoutResult(checkoutObj, paymentIntent, subscription, ensuredProducts.generatedData, products); return paymentProviderCheckoutResult; } buildCheckoutResult(checkoutObj, intent, subscription, providerProducts, products) { const success = intent.status === "succeeded" && intent.object === "payment_intent"; const originalErrorMessage = "errorMessage" in checkoutObj ? checkoutObj.errorMessage : undefined; const serverErrorMessage = intent.object === "setup_intent" ? intent.last_setup_error?.message : intent.last_payment_error?.message; return { ...checkoutObj, externalId: intent.id, externalData: { ...checkoutObj.externalData, data: { ...checkoutObj.externalData.data, intentStatus: intent.status, clientSecret: intent.client_secret, finalPaymentIntent: intent, } }, externalProductData: providerProducts, success: success, errorMessage: success ? undefined : (originalErrorMessage || serverErrorMessage), subscription: subscription && { provider: this.provider, externalId: subscription.id, productIds: products.map(it => it.getId()), nextBill: new Date(subscription.current_period_end * 1000), planningCancelDate: subscription.cancel_at !== null ? new Date(subscription.cancel_at * 1000) : undefined, } }; } async ensureStripeProducts(products) { let ensuredProducts = []; let generatedStripeData = []; for (let product of products) { const paymentDataList = product.externalPaymentData || []; if (paymentDataList.find(it => it.provider === this.provider)) { ensuredProducts.push(product); } else { const stripeProduct = await this.stripe.products.create({ id: product.code, name: product.code }); const paymentData = { data: stripeProduct, provider: this.provider }; generatedStripeData.push({ productId: product.getId(), providerData: paymentData }); ensuredProducts.push({ ...product, externalPaymentData: [...paymentDataList, paymentData] }); } } return { products: ensuredProducts, generatedData: generatedStripeData }; } ensureStripeAccount(user) { const stripeAccount = this._getStripeAccount(user); if (!stripeAccount) return this._createStripeAccount(user); } _getStripeAccount(user) { const paymentAccounts = user.payment?.externalProviderAccounts; const stripeExistingAccount = (paymentAccounts && paymentAccounts.find(it => it.provider === this.provider)); return stripeExistingAccount; } async _createStripeAccount(user) { const customerResponse = await this.stripe.customers.create({ email: user.email, metadata: { userId: user.getId() } }); const { lastResponse, ...customer } = customerResponse; const stripeAccount = { provider: this.provider, customer: customer, paymentSources: [] }; return stripeAccount; } getIdFromStringOrObject(object) { return typeof object === "object" ? object.id : object; } async get3dsResultFromSetupIntent(intent) { let latestAttempt; if (typeof intent.latest_attempt === "object" && intent.latest_attempt) { latestAttempt = intent.latest_attempt; } if (typeof intent.latest_attempt === "string") { latestAttempt = (await this.stripe.setupAttempts.list({ setup_intent: intent.id }))?.data[0]; } if (!latestAttempt) throw "Did not find latest attempt"; let threeDSecure = latestAttempt.payment_method_details.card?.three_d_secure; if (!threeDSecure) throw "Unexpected error, did not find 3ds result"; return threeDSecure; } async get3dsResultFromPaymentIntent(intent) { const charge = intent.charges.data[0]; if (!charge) throw "Unexpected error, did not find 3ds result"; let threeDSecure = charge.payment_method_details?.card?.three_d_secure; if (!threeDSecure) throw "Unexpected error, did not find 3ds result"; return threeDSecure; } } //# sourceMappingURL=payment-stripe-server-provider.js.map