UNPKG

@vendure/core

Version:

A modern, headless ecommerce framework

415 lines 22.5 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PaymentService = void 0; const common_1 = require("@nestjs/common"); const shared_utils_1 = require("@vendure/common/lib/shared-utils"); const typeorm_1 = require("typeorm"); const errors_1 = require("../../common/error/errors"); const generated_graphql_admin_errors_1 = require("../../common/error/generated-graphql-admin-errors"); const generated_graphql_shop_errors_1 = require("../../common/error/generated-graphql-shop-errors"); const instrument_decorator_1 = require("../../common/instrument-decorator"); const utils_1 = require("../../common/utils"); const vendure_logger_1 = require("../../config/logger/vendure-logger"); const transactional_connection_1 = require("../../connection/transactional-connection"); const fulfillment_entity_1 = require("../../entity/fulfillment/fulfillment.entity"); const order_entity_1 = require("../../entity/order/order.entity"); const order_line_entity_1 = require("../../entity/order-line/order-line.entity"); const refund_line_entity_1 = require("../../entity/order-line-reference/refund-line.entity"); const payment_entity_1 = require("../../entity/payment/payment.entity"); const refund_entity_1 = require("../../entity/refund/refund.entity"); const event_bus_1 = require("../../event-bus/event-bus"); const payment_state_transition_event_1 = require("../../event-bus/events/payment-state-transition-event"); const refund_state_transition_event_1 = require("../../event-bus/events/refund-state-transition-event"); const payment_state_machine_1 = require("../helpers/payment-state-machine/payment-state-machine"); const refund_state_machine_1 = require("../helpers/refund-state-machine/refund-state-machine"); const payment_method_service_1 = require("./payment-method.service"); /** * @description * Contains methods relating to {@link Payment} entities. * * @docsCategory services */ let PaymentService = class PaymentService { constructor(connection, paymentStateMachine, refundStateMachine, paymentMethodService, eventBus) { this.connection = connection; this.paymentStateMachine = paymentStateMachine; this.refundStateMachine = refundStateMachine; this.paymentMethodService = paymentMethodService; this.eventBus = eventBus; } async create(ctx, input) { const newPayment = new payment_entity_1.Payment(Object.assign(Object.assign({}, input), { state: this.paymentStateMachine.getInitialState() })); return this.connection.getRepository(ctx, payment_entity_1.Payment).save(newPayment); } async findOneOrThrow(ctx, id, relations = ['order']) { return await this.connection.getEntityOrThrow(ctx, payment_entity_1.Payment, id, { relations, }); } /** * @description * Transitions a Payment to the given state. * * When updating a Payment in the context of an Order, it is * preferable to use the {@link OrderService} `transitionPaymentToState()` method, which will also handle * updating the Order state too. */ async transitionToState(ctx, paymentId, state) { if (state === 'Settled') { return this.settlePayment(ctx, paymentId); } if (state === 'Cancelled') { return this.cancelPayment(ctx, paymentId); } const payment = await this.findOneOrThrow(ctx, paymentId); const fromState = payment.state; return this.transitionStateAndSave(ctx, payment, fromState, state); } getNextStates(payment) { return this.paymentStateMachine.getNextStates(payment); } /** * @description * Creates a new Payment. * * When creating a Payment in the context of an Order, it is * preferable to use the {@link OrderService} `addPaymentToOrder()` method, which will also handle * updating the Order state too. */ async createPayment(ctx, order, amount, method, metadata) { const { paymentMethod, handler, checker } = await this.paymentMethodService.getMethodAndOperations(ctx, method); if (paymentMethod.checker && checker) { const eligible = await checker.check(ctx, order, paymentMethod.checker.args, paymentMethod); if (eligible === false || typeof eligible === 'string') { return new generated_graphql_shop_errors_1.IneligiblePaymentMethodError({ eligibilityCheckerMessage: typeof eligible === 'string' ? eligible : undefined, }); } } const result = await handler.createPayment(ctx, order, amount, paymentMethod.handler.args, metadata || {}, paymentMethod); const initialState = 'Created'; // The DB-write sequence below (payment create, state transition, save, relation // add, onTransitionEnd hooks) is wrapped in withTransaction so it commits or // rolls back atomically — this is what fixes #4686 for this method. Note that // handler.createPayment above is intentionally outside the transaction (it is // a network call to a third-party gateway) and is NOT covered by this wrap. // If the gateway succeeds and a subsequent DB write fails, the resulting // orphaned charge must be reconciled at a higher level. Likewise, the `order` // entity was loaded by the caller outside this transaction. return this.connection.withTransaction(ctx, async (txCtx) => { const payment = await this.connection .getRepository(txCtx, payment_entity_1.Payment) .save(new payment_entity_1.Payment(Object.assign(Object.assign({}, result), { method, state: initialState }))); const { finalize } = await this.paymentStateMachine.transition(txCtx, order, payment, result.state); await this.connection.getRepository(txCtx, payment_entity_1.Payment).save(payment, { reload: false }); await this.connection .getRepository(txCtx, order_entity_1.Order) .createQueryBuilder() .relation('payments') .of(order) .add(payment); await this.eventBus.publish(new payment_state_transition_event_1.PaymentStateTransitionEvent(initialState, result.state, txCtx, payment, order)); await finalize(); return payment; }); } /** * @description * Settles a Payment. * * When settling a Payment in the context of an Order, it is * preferable to use the {@link OrderService} `settlePayment()` method, which will also handle * updating the Order state too. */ async settlePayment(ctx, paymentId) { const payment = await this.connection.getEntityOrThrow(ctx, payment_entity_1.Payment, paymentId, { relations: ['order'], }); const { paymentMethod, handler } = await this.paymentMethodService.getMethodAndOperations(ctx, payment.method); const settlePaymentResult = await handler.settlePayment(ctx, payment.order, payment, paymentMethod.handler.args, paymentMethod); const fromState = payment.state; let toState; payment.metadata = this.mergePaymentMetadata(payment.metadata, settlePaymentResult.metadata); if (settlePaymentResult.success) { toState = 'Settled'; } else { toState = settlePaymentResult.state || 'Error'; payment.errorMessage = settlePaymentResult.errorMessage; } return this.transitionStateAndSave(ctx, payment, fromState, toState); } async cancelPayment(ctx, paymentId) { const payment = await this.connection.getEntityOrThrow(ctx, payment_entity_1.Payment, paymentId, { relations: ['order'], }); const { paymentMethod, handler } = await this.paymentMethodService.getMethodAndOperations(ctx, payment.method); const cancelPaymentResult = await handler.cancelPayment(ctx, payment.order, payment, paymentMethod.handler.args, paymentMethod); const fromState = payment.state; let toState; payment.metadata = this.mergePaymentMetadata(payment.metadata, cancelPaymentResult === null || cancelPaymentResult === void 0 ? void 0 : cancelPaymentResult.metadata); if (cancelPaymentResult == null || cancelPaymentResult.success) { toState = 'Cancelled'; } else { toState = cancelPaymentResult.state || 'Error'; payment.errorMessage = cancelPaymentResult.errorMessage; } return this.transitionStateAndSave(ctx, payment, fromState, toState); } async transitionStateAndSave(ctx, payment, fromState, toState) { if (fromState === toState) { // in case metadata was changed await this.connection.getRepository(ctx, payment_entity_1.Payment).save(payment, { reload: false }); return payment; } // Wrapped in withTransaction so the state save and onTransitionEnd hooks // are atomic — see the equivalent comment on OrderService.transitionToState. // #4686. return this.connection.withTransaction(ctx, async (txCtx) => { let finalize; try { const result = await this.paymentStateMachine.transition(txCtx, payment.order, payment, toState); finalize = result.finalize; } catch (e) { const transitionError = txCtx.translate(e.message, { fromState, toState }); return new generated_graphql_admin_errors_1.PaymentStateTransitionError({ transitionError, fromState, toState }); } await this.connection.getRepository(txCtx, payment_entity_1.Payment).save(payment, { reload: false }); await this.eventBus.publish(new payment_state_transition_event_1.PaymentStateTransitionEvent(fromState, toState, txCtx, payment, payment.order)); await finalize(); return payment; }); } /** * @description * Creates a Payment from the manual payment mutation in the Admin API * * When creating a manual Payment in the context of an Order, it is * preferable to use the {@link OrderService} `addManualPaymentToOrder()` method, which will also handle * updating the Order state too. */ async createManualPayment(ctx, order, amount, input) { const initialState = 'Created'; const endState = 'Settled'; // Wrapped in withTransaction so the payment create, state transition, save, // relation add and onTransitionEnd hooks all commit or roll back together. // #4686. return this.connection.withTransaction(ctx, async (txCtx) => { const payment = await this.connection.getRepository(txCtx, payment_entity_1.Payment).save(new payment_entity_1.Payment({ amount, order, transactionId: input.transactionId, metadata: input.metadata, method: input.method, state: initialState, })); const { finalize } = await this.paymentStateMachine.transition(txCtx, order, payment, endState); await this.connection.getRepository(txCtx, payment_entity_1.Payment).save(payment, { reload: false }); await this.connection .getRepository(txCtx, order_entity_1.Order) .createQueryBuilder() .relation('payments') .of(order) .add(payment); await this.eventBus.publish(new payment_state_transition_event_1.PaymentStateTransitionEvent(initialState, endState, txCtx, payment, order)); await finalize(); return payment; }); } /** * @description * Creates a Refund against the specified Payment. If the amount to be refunded exceeds the value of the * specified Payment (in the case of multiple payments on a single Order), then the remaining outstanding * refund amount will be refunded against the next available Payment from the Order. * * When creating a Refund in the context of an Order, it is * preferable to use the {@link OrderService} `refundOrder()` method, which performs additional * validation. */ async createRefund(ctx, input, order, selectedPayment) { var _a, _b; const orderWithRefunds = await this.connection.getEntityOrThrow(ctx, order_entity_1.Order, order.id, { relations: ['payments', 'payments.refunds'], }); if (input.amount) { const paymentToRefund = orderWithRefunds.payments.find(p => (0, utils_1.idsAreEqual)(p.id, selectedPayment.id)); if (!paymentToRefund) { throw new errors_1.InternalServerError('Could not find a Payment to refund'); } const refundableAmount = paymentToRefund.amount - this.getPaymentRefundTotal(paymentToRefund); if (refundableAmount < input.amount) { return new generated_graphql_admin_errors_1.RefundAmountError({ maximumRefundable: refundableAmount }); } } const refundsCreated = []; const refundablePayments = orderWithRefunds.payments.filter(p => { return this.getPaymentRefundTotal(p) < p.amount; }); let primaryRefund; const refundedPaymentIds = []; const { total, orderLinesTotal } = await this.getRefundAmount(ctx, input); const refundMax = (_b = (_a = orderWithRefunds.payments) === null || _a === void 0 ? void 0 : _a.map(p => p.amount - this.getPaymentRefundTotal(p)).reduce((sum, amount) => sum + amount, 0)) !== null && _b !== void 0 ? _b : 0; let refundOutstanding = Math.min(total, refundMax); do { const paymentToRefund = (refundedPaymentIds.length === 0 && refundablePayments.find(p => (0, utils_1.idsAreEqual)(p.id, selectedPayment.id))) || refundablePayments.find(p => !refundedPaymentIds.includes(p.id)); if (!paymentToRefund) { throw new errors_1.InternalServerError('Could not find a Payment to refund'); } const amountNotRefunded = paymentToRefund.amount - this.getPaymentRefundTotal(paymentToRefund); const constrainedTotal = Math.min(amountNotRefunded, refundOutstanding); let refund = new refund_entity_1.Refund({ payment: paymentToRefund, total: constrainedTotal, reason: input.reason, method: selectedPayment.method, state: 'Pending', metadata: {}, items: orderLinesTotal, // deprecated adjustment: input.adjustment, // deprecated shipping: input.shipping, // deprecated }); let paymentMethod; let handler; try { const methodAndHandler = await this.paymentMethodService.getMethodAndOperations(ctx, paymentToRefund.method); paymentMethod = methodAndHandler.paymentMethod; handler = methodAndHandler.handler; } catch (e) { vendure_logger_1.Logger.warn('Could not find a corresponding PaymentMethodHandler ' + `when creating a refund for the Payment with method "${paymentToRefund.method}"`); } const createRefundResult = paymentMethod && handler ? await handler.createRefund(ctx, input, constrainedTotal, order, paymentToRefund, paymentMethod.handler.args, paymentMethod) : false; if (createRefundResult) { refund.transactionId = createRefundResult.transactionId || ''; refund.metadata = createRefundResult.metadata || {}; } refund = await this.connection.getRepository(ctx, refund_entity_1.Refund).save(refund); const refundLines = []; for (const { orderLineId, quantity } of input.lines || []) { const refundLine = await this.connection.getRepository(ctx, refund_line_entity_1.RefundLine).save(new refund_line_entity_1.RefundLine({ refund, orderLineId, quantity, })); refundLines.push(refundLine); } await this.connection .getRepository(ctx, fulfillment_entity_1.Fulfillment) .createQueryBuilder() .relation('lines') .of(refund) .add(refundLines); if (createRefundResult) { const fromState = refund.state; // Each iteration's state transition is wrapped in withTransaction so // the save, onTransitionEnd hooks and event publish commit or roll // back together — same atomicity guarantee as the dedicated // transition methods. The surrounding loop is intentionally NOT // wrapped: refunds created in earlier iterations remain committed // if a later iteration fails. #4686. const transitionError = await this.connection.withTransaction(ctx, async (txCtx) => { let finalize; try { const result = await this.refundStateMachine.transition(txCtx, order, refund, createRefundResult.state); finalize = result.finalize; } catch (e) { return new generated_graphql_admin_errors_1.RefundStateTransitionError({ transitionError: e.message, fromState, toState: createRefundResult.state, }); } await this.connection.getRepository(txCtx, refund_entity_1.Refund).save(refund, { reload: false }); await finalize(); await this.eventBus.publish(new refund_state_transition_event_1.RefundStateTransitionEvent(fromState, createRefundResult.state, txCtx, refund, order)); return undefined; }); if (transitionError) { return transitionError; } } if (primaryRefund == null) { primaryRefund = refund; } refundsCreated.push(refund); refundedPaymentIds.push(paymentToRefund.id); refundOutstanding = total - (0, shared_utils_1.summate)(refundsCreated, 'total'); } while (0 < refundOutstanding); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return primaryRefund; } /** * @description * Returns the total amount of all Refunds against the given Payment. */ getPaymentRefundTotal(payment) { var _a, _b; const nonFailedRefunds = (_b = (_a = payment.refunds) === null || _a === void 0 ? void 0 : _a.filter(refund => refund.state !== 'Failed')) !== null && _b !== void 0 ? _b : []; return (0, shared_utils_1.summate)(nonFailedRefunds, 'total'); } async getRefundAmount(ctx, input) { var _a, _b; if (input.amount) { // This is the new way of getting the refund amount // after v2.2.0. It allows full control over the refund. return { orderLinesTotal: 0, total: input.amount }; } // This is the pre-v2.2.0 way of getting the refund amount. // It calculates the refund amount based on the order lines to be refunded // plus shipping and adjustment amounts. It is complex and prevents full // control over refund amounts, especially when multiple payment methods // are involved. // It is deprecated and will be removed in a future version. let refundOrderLinesTotal = 0; const inputLines = input.lines || []; const orderLines = await this.connection .getRepository(ctx, order_line_entity_1.OrderLine) .find({ where: { id: (0, typeorm_1.In)(inputLines.map(l => l.orderLineId)) } }); for (const line of inputLines) { const orderLine = orderLines.find(l => (0, utils_1.idsAreEqual)(l.id, line.orderLineId)); if (orderLine && 0 < orderLine.orderPlacedQuantity) { refundOrderLinesTotal += line.quantity * orderLine.proratedUnitPriceWithTax; } } const total = refundOrderLinesTotal + ((_a = input.shipping) !== null && _a !== void 0 ? _a : 0) + ((_b = input.adjustment) !== null && _b !== void 0 ? _b : 0); return { orderLinesTotal: refundOrderLinesTotal, total }; } mergePaymentMetadata(m1, m2) { if (!m2) { return m1; } const merged = Object.assign(Object.assign({}, m1), m2); if (m1.public && m1.public) { merged.public = Object.assign(Object.assign({}, m1.public), m2.public); } return merged; } }; exports.PaymentService = PaymentService; exports.PaymentService = PaymentService = __decorate([ (0, common_1.Injectable)(), (0, instrument_decorator_1.Instrument)(), __metadata("design:paramtypes", [transactional_connection_1.TransactionalConnection, payment_state_machine_1.PaymentStateMachine, refund_state_machine_1.RefundStateMachine, payment_method_service_1.PaymentMethodService, event_bus_1.EventBus]) ], PaymentService); //# sourceMappingURL=payment.service.js.map