@liberquack/utils
Version:
Quack utilities
375 lines • 18.3 kB
JavaScript
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