Skip to content

Commit

Permalink
feat: Support refund and cancel for Stripe payment integration #676
Browse files Browse the repository at this point in the history
  • Loading branch information
treoden committed Dec 26, 2024
1 parent fab7f9e commit 2a53f41
Show file tree
Hide file tree
Showing 11 changed files with 316 additions and 5 deletions.
12 changes: 12 additions & 0 deletions packages/evershop/src/modules/oms/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,12 @@ module.exports = () => {
hookAfter(
'changePaymentStatus',
async (order, orderId, status, connection) => {
if (order.status === 'canceled') {
throw new Error('Order is already canceled');
}
if (order.status === 'closed') {
throw new Error('Order is already closed');
}
const orderStatus = resolveOrderStatus(status, order.shipment_status);
await changeOrderStatus(order, orderStatus, connection);
}
Expand All @@ -370,6 +376,12 @@ module.exports = () => {
hookAfter(
'changeShipmentStatus',
async (order, orderId, status, connection) => {
if (order.status === 'canceled') {
throw new Error('Order is already canceled');
}
if (order.status === 'closed') {
throw new Error('Order is already closed');
}
const orderStatus = resolveOrderStatus(order.payment_status, status);
await changeOrderStatus(order, orderStatus, connection);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ Represents a payment transaction
type PaymentTransaction {
paymentTransactionId: Int!
uuid: String!
transactionId: String!
transactionId: String
transactionType: String!
amount: Price!
parentTransactionId: String!
parentTransactionId: String
paymentAction: String!
additionalInformation: String!
createdAt: String!
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"methods": ["POST"],
"path": "/stripe/paymentIntents/capture",
"access": "public"
"access": "private"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const bodyParser = require('body-parser');

module.exports = (request, response, delegate, next) => {
bodyParser.json({ inflate: false })(request, response, next);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"type": "object",
"properties": {
"order_id": {
"type": "string"
},
"amount": {
"type": ["string", "number"],
"pattern": "^\\d+(\\.\\d{1,2})?$",
"errorMessage": {
"pattern": "Amount should be a number with maximum 2 decimal places"
}
}
},
"required": ["order_id"],
"additionalProperties": true,
"errorMessage": {
"properties": {
"order_id": "Order is invalid"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
const stripePayment = require('stripe');
const smallestUnit = require('zero-decimal-currencies');
const { getConfig } = require('@evershop/evershop/src/lib/util/getConfig');
const {
OK,
INVALID_PAYLOAD,
INTERNAL_SERVER_ERROR
} = require('@evershop/evershop/src/lib/util/httpStatus');
const { error } = require('@evershop/evershop/src/lib/log/logger');
const { pool } = require('@evershop/evershop/src/lib/postgres/connection');
const {
select,
getConnection,
startTransaction,
insert,
commit,
rollback
} = require('@evershop/postgres-query-builder');
const { getSetting } = require('../../../setting/services/setting');
const {
updatePaymentStatus
} = require('../../../oms/services/updatePaymentStatus');

// eslint-disable-next-line no-unused-vars
module.exports = async (request, response, delegate, next) => {
const connection = await getConnection(pool);
try {
await startTransaction(connection);
// eslint-disable-next-line camelcase
const { order_id, amount } = request.body;
// Load the order
const order = await select()
.from('order')
.where('order_id', '=', order_id)
.load(connection);
if (!order || order.payment_method !== 'stripe') {
response.status(INVALID_PAYLOAD);
response.json({
error: {
status: INVALID_PAYLOAD,
message: 'Invalid order'
}
});
return;
}

// Get the payment transaction
const paymentTransaction = await select()
.from('payment_transaction')
.where('payment_transaction_order_id', '=', order.order_id)
.load(connection);
if (!paymentTransaction) {
response.status(INVALID_PAYLOAD);
response.json({
error: {
status: INVALID_PAYLOAD,
message: 'Can not find payment transaction'
}
});
return;
}

const stripeConfig = getConfig('system.stripe', {});
let stripeSecretKey;

if (stripeConfig.secretKey) {
stripeSecretKey = stripeConfig.secretKey;
} else {
stripeSecretKey = await getSetting('stripeSecretKey', '');
}
const stripe = stripePayment(stripeSecretKey);
// Refund
const refund = await stripe.refunds.create({
payment_intent: paymentTransaction.transaction_id,
amount: smallestUnit.default(amount, order.currency)
});
const charge = await stripe.charges.retrieve(refund.charge);
// Update the order status
const status = charge.refunded === true ? 'refunded' : 'partial_refunded';
await updatePaymentStatus(order.order_id, status, connection);
await insert('order_activity')
.given({
order_activity_order_id: order.order_id,
comment: `Refunded ${amount} ${charge.currency}`
})
.execute(connection);
await commit(connection);
response.status(OK);
response.json({
data: {
amount: refund.amount
}
});
} catch (err) {
error(err);
await rollback(connection);
response.status(INTERNAL_SERVER_ERROR);
response.json({
error: {
status: INTERNAL_SERVER_ERROR,
message: err.message
}
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"methods": ["POST"],
"path": "/stripe/paymentIntents/refund",
"access": "private"
}
21 changes: 20 additions & 1 deletion packages/evershop/src/modules/stripe/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,29 @@ module.exports = () => {
name: 'Authorized',
badge: 'attention',
progress: 'incomplete'
},
failed: {
name: 'Failed',
badge: 'critical',
progress: 'failed'
},
refunded: {
name: 'Refunded',
badge: 'critical',
progress: 'complete'
},
partial_refunded: {
name: 'Partial Refunded',
badge: 'critical',
progress: 'incomplete'
}
},
psoMapping: {
'authorized:*': 'processing'
'authorized:*': 'processing',
'failed:*': 'new',
'refunded:*': 'closed',
'partial_refunded:*': 'processing',
'partial_refunded:delivered': 'completed'
}
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React from 'react';
import PropTypes from 'prop-types';
import { toast } from 'react-toastify';
import Button from '@components/common/form/Button';
import RenderIfTrue from '@components/common/RenderIfTrue';
import { useAlertContext } from '@components/common/modal/Alert';
import { Card } from '@components/admin/cms/Card';
import { Form } from '@components/common/form/Form';
import { Field } from '@components/common/form/Field';

export default function StripeRefundButton({
refundAPI,
order: { paymentStatus, orderId, paymentMethod, grandTotal }
}) {
const { openAlert, closeAlert, dispatchAlert } = useAlertContext();
return (
<RenderIfTrue
condition={
paymentMethod === 'stripe' &&
['paid', 'partial_refunded'].includes(paymentStatus.code)
}
>
<Card.Session>
<div className="flex justify-end">
<Button
title="Refund"
variant="secondary"
onAction={() => {
openAlert({
heading: 'Refund',
content: (
<div>
<Form
id="stripeRefund"
method="POST"
action={refundAPI}
submitBtn={false}
isJSON
onSuccess={(response) => {
if (response.error) {
toast.error(response.error.message);
dispatchAlert({
type: 'update',
payload: { secondaryAction: { isLoading: false } }
});
} else {
// Reload the page
window.location.reload();
}
}}
onValidationError={() => {
dispatchAlert({
type: 'update',
payload: { secondaryAction: { isLoading: false } }
});
}}
>
<div>
<Field
formId="stripeRefund"
type="text"
name="amount"
label="Refund amount"
placeHolder="Refund amount"
value={grandTotal.value}
validationRules={['notEmpty']}
suffix={grandTotal.currency}
/>
</div>
<input type="hidden" name="order_id" value={orderId} />
</Form>
</div>
),
primaryAction: {
title: 'Cancel',
onAction: closeAlert,
variant: ''
},
secondaryAction: {
title: 'Refund',
onAction: () => {
dispatchAlert({
type: 'update',
payload: { secondaryAction: { isLoading: true } }
});
document
.getElementById('stripeRefund')
.dispatchEvent(
new Event('submit', { cancelable: true, bubbles: true })
);
},
variant: 'primary',
isLoading: false
}
});
}}
/>
</div>
</Card.Session>
</RenderIfTrue>
);
}

StripeRefundButton.propTypes = {
refundAPI: PropTypes.string.isRequired,
order: PropTypes.shape({
paymentStatus: PropTypes.shape({
code: PropTypes.string.isRequired
}).isRequired,
orderId: PropTypes.string.isRequired,
paymentMethod: PropTypes.string.isRequired,
grandTotal: PropTypes.shape({
value: PropTypes.number.isRequired,
currency: PropTypes.string.isRequired
}).isRequired
}).isRequired
};

export const layout = {
areaId: 'orderPaymentActions',
sortOrder: 10
};

export const query = `
query Query {
refundAPI: url(routeId: "refundPaymentIntent")
order(uuid: getContextValue("orderId")) {
orderId
grandTotal {
value
currency
}
paymentStatus {
code
}
paymentMethod
}
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const {
addNotification
} = require('@evershop/evershop/src/modules/base/services/notifications');
const { error } = require('@evershop/evershop/src/lib/log/logger');
const {
updatePaymentStatus
} = require('@evershop/evershop/src/modules/oms/services/updatePaymentStatus');

// eslint-disable-next-line no-unused-vars
module.exports = async (request, response, delegate, next) => {
Expand Down Expand Up @@ -54,6 +57,7 @@ module.exports = async (request, response, delegate, next) => {
.given({ status: true })
.where('cart_id', '=', order.cart_id)
.execute(pool);
await updatePaymentStatus(order.order_id, 'failed');
// Add a error notification
addNotification(request, 'Payment failed', 'error');
request.session.save(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async function cancelPaymentIntent(orderID) {
.where('payment_transaction_order_id', '=', orderID)
.load(pool);
if (!transaction) {
throw new Error('Can not find payment transaction');
return;
}
const stripeConfig = getConfig('system.stripe', {});
let stripeSecretKey;
Expand Down

0 comments on commit 2a53f41

Please sign in to comment.