@medusajs/payment-stripe
Version:
Stripe payment provider for Medusa
470 lines • 20.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const stripe_1 = __importDefault(require("stripe"));
const promises_1 = require("timers/promises");
const utils_1 = require("@medusajs/framework/utils");
const types_1 = require("../types");
const get_smallest_unit_1 = require("../utils/get-smallest-unit");
class StripeBase extends utils_1.AbstractPaymentProvider {
static validateOptions(options) {
if (!(0, utils_1.isDefined)(options.apiKey)) {
throw new Error("Required option `apiKey` is missing in Stripe plugin");
}
}
constructor(cradle, options) {
// @ts-ignore
super(...arguments);
this.container_ = cradle;
this.options_ = options;
this.stripe_ = new stripe_1.default(options.apiKey);
}
get options() {
return this.options_;
}
normalizePaymentIntentParameters(extra) {
const res = {};
res.description = (extra?.payment_description ??
this.options_?.paymentDescription);
res.capture_method =
extra?.capture_method ??
this.paymentIntentOptions.capture_method ??
(this.options_.capture ? "automatic" : "manual");
res.setup_future_usage =
extra?.setup_future_usage ??
this.paymentIntentOptions.setup_future_usage;
res.payment_method_types =
extra?.payment_method_types ??
this.paymentIntentOptions.payment_method_types;
res.payment_method_data =
extra?.payment_method_data;
res.payment_method_options =
extra?.payment_method_options ??
this.paymentIntentOptions.payment_method_options;
res.automatic_payment_methods =
extra?.automatic_payment_methods ??
(this.options_?.automaticPaymentMethods ? { enabled: true } : undefined);
res.off_session = extra?.off_session;
res.confirm = extra?.confirm;
res.payment_method = extra?.payment_method;
res.return_url = extra?.return_url;
// @ts-expect-error - Need to update Stripe SDK
res.shared_payment_token = extra?.shared_payment_token;
return res;
}
handleStripeError(error) {
switch (error.type) {
case "StripeCardError":
// Stripe has created a payment intent but it failed
// Extract and return paymentIntent object to be stored in payment_session
// Allows for reference to the failed intent and potential webhook reconciliation
const stripeError = error.raw;
if (stripeError.payment_intent) {
return {
retry: false,
data: stripeError.payment_intent,
};
}
else {
throw this.buildError("An error occurred in InitiatePayment during creation of stripe payment intent", error);
}
case "StripeConnectionError":
case "StripeRateLimitError":
// Connection or rate limit errors indicate an uncertain result
// Retry the operation
return {
retry: true,
};
case "StripeAPIError": {
// API errors should be treated as indeterminate per Stripe documentation
// Rely on webhooks rather than assuming failure
return {
retry: false,
data: {
indeterminate_due_to: "stripe_api_error",
},
};
}
default:
// For all other errors, there was likely an issue creating the session
// on Stripe's servers. Throw an error which will trigger cleanup
// and deletion of the payment session.
throw this.buildError("An error occurred in InitiatePayment during creation of stripe payment intent", error);
}
}
async executeWithRetry(apiCall, maxRetries = 3, baseDelay = 1000, currentAttempt = 1) {
try {
return await apiCall();
}
catch (error) {
const handledError = this.handleStripeError(error);
if (!handledError.retry) {
// If retry is false, we know data exists per the type definition
return handledError.data;
}
if (handledError.retry && currentAttempt <= maxRetries) {
// Logic for retrying
const delay = baseDelay *
Math.pow(2, currentAttempt - 1) *
(0.5 + Math.random() * 0.5);
await (0, promises_1.setTimeout)(delay);
return this.executeWithRetry(apiCall, maxRetries, baseDelay, currentAttempt + 1);
}
// Retries are exhausted
throw this.buildError("An error occurred in InitiatePayment during creation of stripe payment intent", error);
}
}
async getPaymentStatus(input) {
const id = input?.data?.id;
if (!id) {
throw this.buildError("No payment intent ID provided while getting payment status", new Error("No payment intent ID provided"));
}
const paymentIntent = await this.stripe_.paymentIntents.retrieve(id);
const statusResponse = this.getStatus(paymentIntent);
return statusResponse;
}
async initiatePayment({ currency_code, amount, data, context, }) {
const additionalParameters = this.normalizePaymentIntentParameters(data);
const intentRequest = {
amount: (0, get_smallest_unit_1.getSmallestUnit)(amount, currency_code),
currency: currency_code,
metadata: {
...(data?.metadata ?? {}),
session_id: data?.session_id,
},
...additionalParameters,
};
intentRequest.customer = context?.account_holder?.data?.id;
const sessionData = await this.executeWithRetry(() => this.stripe_.paymentIntents.create(intentRequest, {
idempotencyKey: context?.idempotency_key,
}));
const isPaymentIntent = "id" in sessionData;
return {
id: isPaymentIntent ? sessionData.id : data?.session_id,
...this.getStatus(sessionData),
};
}
async authorizePayment(input) {
return this.getPaymentStatus(input);
}
async cancelPayment({ data, context, }) {
try {
const id = data?.id;
if (!id) {
return { data: data };
}
const res = await this.stripe_.paymentIntents.cancel(id, {
idempotencyKey: context?.idempotency_key,
});
return { data: res };
}
catch (error) {
if (error.payment_intent?.status === types_1.ErrorIntentStatus.CANCELED) {
return { data: error.payment_intent };
}
throw this.buildError("An error occurred in cancelPayment", error);
}
}
async capturePayment({ data, context, }) {
const id = data?.id;
try {
const intent = await this.stripe_.paymentIntents.capture(id, {
idempotencyKey: context?.idempotency_key,
});
return { data: intent };
}
catch (error) {
if (error.code === types_1.ErrorCodes.PAYMENT_INTENT_UNEXPECTED_STATE) {
if (error.payment_intent?.status === types_1.ErrorIntentStatus.SUCCEEDED) {
return { data: error.payment_intent };
}
}
throw this.buildError("An error occurred in capturePayment", error);
}
}
async deletePayment(input) {
return await this.cancelPayment(input);
}
async refundPayment({ amount, data, context, }) {
const id = data?.id;
if (!id) {
throw this.buildError("No payment intent ID provided while refunding payment", new Error("No payment intent ID provided"));
}
try {
const currencyCode = data?.currency;
await this.stripe_.refunds.create({
amount: (0, get_smallest_unit_1.getSmallestUnit)(amount, currencyCode),
payment_intent: id,
}, {
idempotencyKey: context?.idempotency_key,
});
}
catch (e) {
if (e.code !== types_1.ErrorCodes.CHARGE_ALREADY_REFUNDED) {
throw this.buildError("An error occurred in refundPayment", e);
}
}
return { data };
}
async retrievePayment({ data, }) {
try {
const id = data?.id;
const intent = await this.stripe_.paymentIntents.retrieve(id);
intent.amount = (0, get_smallest_unit_1.getAmountFromSmallestUnit)(intent.amount, intent.currency);
return { data: intent };
}
catch (e) {
throw this.buildError("An error occurred in retrievePayment", e);
}
}
async updatePayment({ data, currency_code, amount, context, }) {
const amountNumeric = (0, get_smallest_unit_1.getSmallestUnit)(amount, currency_code);
if ((0, utils_1.isPresent)(amount) && data?.amount === amountNumeric) {
return this.getStatus(data);
}
try {
const id = data?.id;
const sessionData = (await this.stripe_.paymentIntents.update(id, {
amount: amountNumeric,
}, {
idempotencyKey: context?.idempotency_key,
}));
return this.getStatus(sessionData);
}
catch (e) {
throw this.buildError("An error occurred in updatePayment", e);
}
}
async createAccountHolder({ context, }) {
const { account_holder, customer, idempotency_key } = context;
if (account_holder?.data?.id) {
return { id: account_holder.data.id };
}
if (!customer) {
throw this.buildError("No customer in context", new Error("No customer provided while creating account holder"));
}
const shipping = customer.billing_address
? {
address: {
city: customer.billing_address.city,
country: customer.billing_address.country_code,
line1: customer.billing_address.address_1,
line2: customer.billing_address.address_2,
postal_code: customer.billing_address.postal_code,
state: customer.billing_address.province,
},
}
: undefined;
try {
const stripeCustomer = await this.stripe_.customers.create({
email: customer.email,
name: customer.company_name ||
`${customer.first_name ?? ""} ${customer.last_name ?? ""}`.trim() ||
undefined,
phone: customer.phone,
...shipping,
}, {
idempotencyKey: idempotency_key,
});
return {
id: stripeCustomer.id,
data: stripeCustomer,
};
}
catch (e) {
throw this.buildError("An error occurred in createAccountHolder when creating a Stripe customer", e);
}
}
async updateAccountHolder({ context, }) {
const { account_holder, customer, idempotency_key } = context;
if (!account_holder?.data?.id) {
throw this.buildError("No account holder in context", new Error("No account holder provided while updating account holder"));
}
// If no customer context was provided, we simply don't update anything within the provider
if (!customer) {
return {};
}
const accountHolderId = account_holder.data.id;
const shipping = customer.billing_address
? {
address: {
city: customer.billing_address.city,
country: customer.billing_address.country_code,
line1: customer.billing_address.address_1,
line2: customer.billing_address.address_2,
postal_code: customer.billing_address.postal_code,
state: customer.billing_address.province,
},
}
: undefined;
try {
const stripeCustomer = await this.stripe_.customers.update(accountHolderId, {
email: customer.email,
name: customer.company_name ||
`${customer.first_name ?? ""} ${customer.last_name ?? ""}`.trim() ||
undefined,
phone: customer.phone,
...shipping,
}, {
idempotencyKey: idempotency_key,
});
return {
data: stripeCustomer,
};
}
catch (e) {
throw this.buildError("An error occurred in updateAccountHolder when updating a Stripe customer", e);
}
}
async deleteAccountHolder({ context, }) {
const { account_holder } = context;
const accountHolderId = account_holder?.data?.id;
if (!accountHolderId) {
throw this.buildError("No account holder in context", new Error("No account holder provided while deleting account holder"));
}
try {
await this.stripe_.customers.del(accountHolderId);
return {};
}
catch (e) {
throw this.buildError("An error occurred in deleteAccountHolder", e);
}
}
async listPaymentMethods({ context, }) {
const accountHolderId = context?.account_holder?.data?.id;
if (!accountHolderId) {
return [];
}
const paymentMethods = await this.stripe_.customers.listPaymentMethods(accountHolderId,
// In order to keep the interface simple, we just list the maximum payment methods, which should be enough in almost all cases.
// We can always extend the interface to allow additional filtering, if necessary.
{ limit: 100 });
return paymentMethods.data.map((method) => ({
id: method.id,
data: method,
}));
}
async savePaymentMethod({ context, data, }) {
const accountHolderId = context?.account_holder?.data?.id;
if (!accountHolderId) {
throw this.buildError("Account holder not set while saving a payment method", new Error("Missing account holder"));
}
const resp = await this.stripe_.setupIntents.create({
customer: accountHolderId,
...data,
}, {
idempotencyKey: context?.idempotency_key,
});
return { id: resp.id, data: resp };
}
getStatus(paymentIntent) {
switch (paymentIntent.status) {
case "requires_payment_method":
if (paymentIntent.last_payment_error) {
return { status: utils_1.PaymentSessionStatus.ERROR, data: paymentIntent };
}
return { status: utils_1.PaymentSessionStatus.PENDING, data: paymentIntent };
case "requires_confirmation":
case "processing":
return { status: utils_1.PaymentSessionStatus.PENDING, data: paymentIntent };
case "requires_action":
return {
status: utils_1.PaymentSessionStatus.REQUIRES_MORE,
data: paymentIntent,
};
case "canceled":
return { status: utils_1.PaymentSessionStatus.CANCELED, data: paymentIntent };
case "requires_capture":
return { status: utils_1.PaymentSessionStatus.AUTHORIZED, data: paymentIntent };
case "succeeded":
return { status: utils_1.PaymentSessionStatus.CAPTURED, data: paymentIntent };
default:
return { status: utils_1.PaymentSessionStatus.PENDING, data: paymentIntent };
}
}
async getWebhookActionAndData(webhookData) {
const event = this.constructWebhookEvent(webhookData);
const intent = event.data.object;
const { currency } = intent;
switch (event.type) {
case "payment_intent.created":
case "payment_intent.processing":
return {
action: utils_1.PaymentActions.PENDING,
data: {
session_id: intent.metadata.session_id,
amount: (0, get_smallest_unit_1.getAmountFromSmallestUnit)(intent.amount, currency),
},
};
case "payment_intent.canceled":
return {
action: utils_1.PaymentActions.CANCELED,
data: {
session_id: intent.metadata.session_id,
amount: (0, get_smallest_unit_1.getAmountFromSmallestUnit)(intent.amount, currency),
},
};
case "payment_intent.payment_failed":
return {
action: utils_1.PaymentActions.FAILED,
data: {
session_id: intent.metadata.session_id,
amount: (0, get_smallest_unit_1.getAmountFromSmallestUnit)(intent.amount, currency),
},
};
case "payment_intent.requires_action":
return {
action: utils_1.PaymentActions.REQUIRES_MORE,
data: {
session_id: intent.metadata.session_id,
amount: (0, get_smallest_unit_1.getAmountFromSmallestUnit)(intent.amount, currency),
},
};
case "payment_intent.amount_capturable_updated":
return {
action: utils_1.PaymentActions.AUTHORIZED,
data: {
session_id: intent.metadata.session_id,
amount: (0, get_smallest_unit_1.getAmountFromSmallestUnit)(intent.amount_capturable, currency),
},
};
case "payment_intent.partially_funded":
return {
action: utils_1.PaymentActions.REQUIRES_MORE,
data: {
session_id: intent.metadata.session_id,
amount: (0, get_smallest_unit_1.getAmountFromSmallestUnit)(intent.next_action?.display_bank_transfer_instructions
?.amount_remaining ?? intent.amount, currency),
},
};
case "payment_intent.succeeded":
return {
action: utils_1.PaymentActions.SUCCESSFUL,
data: {
session_id: intent.metadata.session_id,
amount: (0, get_smallest_unit_1.getAmountFromSmallestUnit)(intent.amount_received, currency),
},
};
default:
return { action: utils_1.PaymentActions.NOT_SUPPORTED };
}
}
/**
* Constructs Stripe Webhook event
* @param {object} data - the data of the webhook request: req.body
* ensures integrity of the webhook event
* @return {object} Stripe Webhook event
*/
constructWebhookEvent(data) {
const signature = data.headers["stripe-signature"];
return this.stripe_.webhooks.constructEvent(data.rawData, signature, this.options_.webhookSecret);
}
buildError(message, error) {
const errorDetails = "raw" in error ? error.raw : error;
return new Error(`${message}: ${error.message}. ${"detail" in errorDetails ? errorDetails.detail : ""}`.trim());
}
}
exports.default = StripeBase;
//# sourceMappingURL=stripe-base.js.map