Skip to content

Commit

Permalink
Merge pull request #25 from a11rew/ag/amount-verification
Browse files Browse the repository at this point in the history
  • Loading branch information
a11rew authored Aug 30, 2023
2 parents db9d908 + 3fec15b commit f41c216
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 14 deletions.
7 changes: 7 additions & 0 deletions .changeset/hot-pianos-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"medusa-payment-paystack": patch
---

Re-adds amount and currency checks

Adds new debug mode for testing
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
node_modules
coverage
coverage
.DS_Store
turbo-build.log
Binary file removed packages/.DS_Store
Binary file not shown.
16 changes: 16 additions & 0 deletions packages/plugin/src/__mocks__/cart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const CartServiceMock = {
retrieveWithTotals: jest.fn().mockImplementation((cartId: string) => {
const amount = cartId === "cart-123" ? 2000 : 1000;

return Promise.resolve({
total: amount,
region: {
currency_code: "ghs",
},
});
}),
};

const mock = jest.fn().mockImplementation(() => CartServiceMock);

export default mock;
4 changes: 3 additions & 1 deletion packages/plugin/src/lib/paystack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export default class Paystack {
id: number;
status: string;
reference: string;
amount: number;
currency: string;
}>
>({
path: "/transaction/verify/" + reference,
Expand Down Expand Up @@ -141,7 +143,7 @@ export default class Paystack {
transaction,
amount,
}: {
transaction: string;
transaction: number;
amount: number;
}) =>
this.requestPaystackAPI<
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import PaystackPaymentProcessor from "../paystack-payment-processor";
import {
PaymentProcessorContext,
PaymentProcessorError,
PaymentSessionStatus,
} from "@medusajs/medusa";

import PaystackPaymentProcessor from "../paystack-payment-processor";
import { CartServiceMock } from "../../__mocks__/cart";

interface ProviderServiceMockOptions {
secretKey?: string | undefined;
}
Expand All @@ -16,8 +18,10 @@ function createPaystackProviderService(
},
) {
return new PaystackPaymentProcessor(
// @ts-expect-error - We don't need to mock all the methods
{},
{
// @ts-expect-error - We don't need to mock all the methods
cartService: CartServiceMock,
},
{
secret_key: secretKey,
},
Expand Down Expand Up @@ -102,6 +106,7 @@ describe("Authorize Payment", () => {
const payment = checkForPaymentProcessorError(
await service.authorizePayment({
paystackTxRef: "123-failed",
cartId: "cart-123",
}),
);
expect(payment.status).toEqual(PaymentSessionStatus.ERROR);
Expand All @@ -113,6 +118,7 @@ describe("Authorize Payment", () => {
const payment = checkForPaymentProcessorError(
await service.authorizePayment({
paystackTxRef: "123-passed",
cartId: "cart-123",
}),
);

Expand All @@ -124,6 +130,7 @@ describe("Authorize Payment", () => {
const payment = checkForPaymentProcessorError(
await service.authorizePayment({
paystackTxRef: "123-false",
cartId: "cart-123",
}),
);

Expand All @@ -136,11 +143,24 @@ describe("Authorize Payment", () => {
const payment = checkForPaymentProcessorError(
await service.authorizePayment({
paystackTxRef: "123-pending",
cartId: "cart-123",
}),
);

expect(payment.status).toEqual(PaymentSessionStatus.PENDING);
});

it("errors out if the returned amount differs", async () => {
const service = createPaystackProviderService();
const payment = checkForPaymentProcessorError(
await service.authorizePayment({
paystackTxRef: "123-passed",
cartId: "cart-1000",
}),
);

expect(payment.status).toEqual(PaymentSessionStatus.ERROR);
});
});

describe("getStatus", () => {
Expand Down Expand Up @@ -231,13 +251,13 @@ describe("refundPayment", () => {
const service = createPaystackProviderService();
const payment = await service.refundPayment(
{
paystackTxId: "paystackTx",
paystackTxId: 1244,
},
4000,
);

expect(payment).toMatchObject({
paystackTxId: "paystackTx",
paystackTxId: 1244,
});
});
});
Expand Down
127 changes: 120 additions & 7 deletions packages/plugin/src/services/paystack-payment-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
PaymentProcessorSessionResponse,
PaymentSessionStatus,
MedusaContainer,
CartService,
} from "@medusajs/medusa";
import { MedusaError, MedusaErrorTypes } from "@medusajs/utils";
import { validateCurrencyCode } from "../utils/currencyCode";
Expand All @@ -22,13 +23,22 @@ export interface PaystackPaymentProcessorConfig {
* https://dashboard.paystack.com/#/settings/developers
*/
secret_key: string;

/**
* Debug mode
* If true, logs helpful debug information to the console
* Logs are prefixed with "PS_P_Debug"
*/
debug?: boolean;
}

class PaystackPaymentProcessor extends AbstractPaymentProcessor {
static identifier = "paystack";

protected readonly cartService: CartService;
protected readonly configuration: PaystackPaymentProcessorConfig;
protected readonly paystack: Paystack;
protected readonly debug: boolean;

constructor(
container: MedusaContainer,
Expand All @@ -45,6 +55,17 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {

this.configuration = options;
this.paystack = new Paystack(this.configuration.secret_key);
this.debug = Boolean(options.debug);

// @ts-expect-error - Container is just an object - https://docs.medusajs.com/development/fundamentals/dependency-injection#in-classes
this.cartService = container.cartService;

if (this.cartService.retrieveWithTotals === undefined) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Your Medusa installation contains an outdated cartService implementation. Update your Medusa installation.",
);
}
}

get paymentIntentOptions() {
Expand All @@ -60,16 +81,24 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {
session_data: {
paystackTxRef: string;
paystackTxAuthData: PaystackTransactionAuthorisation;
cartId: string;
};
})
> {
if (this.debug) {
console.info(
"PS_P_Debug: InitiatePayment",
JSON.stringify(context, null, 2),
);
}

const { amount, email, currency_code } = context;

const validatedCurrencyCode = validateCurrencyCode(currency_code);

const { data, status, message } =
await this.paystack.transaction.initialize({
amount: amount * 100, // Paystack expects amount in lowest denomination - https://paystack.com/docs/payments/accept-payments/#initialize-transaction-1
amount: amount, // Paystack expects amount in lowest denomination - https://paystack.com/docs/payments/accept-payments/#initialize-transaction-1
email,
currency: validatedCurrencyCode,
});
Expand All @@ -84,6 +113,7 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {
session_data: {
paystackTxRef: data.reference,
paystackTxAuthData: data,
cartId: context.resource_id,
},
};
}
Expand All @@ -97,6 +127,13 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {
): Promise<
PaymentProcessorSessionResponse["session_data"] | PaymentProcessorError
> {
if (this.debug) {
console.info(
"PS_P_Debug: UpdatePaymentData",
JSON.stringify({ _, data }, null, 2),
);
}

if (data.amount) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
Expand All @@ -122,6 +159,13 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {
};
})
> {
if (this.debug) {
console.info(
"PS_P_Debug: UpdatePayment",
JSON.stringify(context, null, 2),
);
}

// Re-initialize the payment
return this.initiatePayment(context);
}
Expand All @@ -131,21 +175,40 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {
* We validate the payment and return a status
*/
async authorizePayment(
paymentSessionData: Record<string, unknown> & { paystackTxRef: string },
paymentSessionData: Record<string, unknown> & {
paystackTxRef: string;
cartId: string;
},
): Promise<
| PaymentProcessorError
| {
status: PaymentSessionStatus;
data: Record<string, unknown>;
}
> {
if (this.debug) {
console.info(
"PS_P_Debug: AuthorizePayment",
JSON.stringify(paymentSessionData, null, 2),
);
}

try {
const { paystackTxRef } = paymentSessionData;
const { paystackTxRef, cartId } = paymentSessionData;

const { status, data } = await this.paystack.transaction.verify({
reference: paystackTxRef,
});

const cart = await this.cartService.retrieveWithTotals(cartId);

if (this.debug) {
console.info(
"PS_P_Debug: AuthorizePayment: Verification",
JSON.stringify({ status, cart, data }, null, 2),
);
}

if (status === false) {
// Invalid key error
return {
Expand All @@ -159,15 +222,44 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {
}

switch (data.status) {
case "success":
// Successful transaction
case "success": {
const amountValid =
Math.round(cart.total) === Math.round(data.amount);
const currencyValid =
cart.region.currency_code === data.currency.toLowerCase();

if (amountValid && currencyValid) {
// Successful transaction
return {
status: PaymentSessionStatus.AUTHORIZED,
data: {
paystackTxId: data.id,
paystackTxData: data,
},
};
}

// Invalid amount or currency
// We refund the transaction
await this.refundPayment(
{
...paymentSessionData,
paystackTxData: data,
paystackTxId: data.id,
},
data.amount,
);

// And return the failed status
return {
status: PaymentSessionStatus.AUTHORIZED,
status: PaymentSessionStatus.ERROR,
data: {
...paymentSessionData,
paystackTxId: data.id,
paystackTxData: data,
},
};
}

case "failed":
// Failed transaction
Expand Down Expand Up @@ -198,6 +290,13 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {
async retrievePayment(
paymentSessionData: Record<string, unknown> & { paystackTxId: string },
): Promise<Record<string, unknown> | PaymentProcessorError> {
if (this.debug) {
console.info(
"PS_P_Debug: RetrievePayment",
JSON.stringify(paymentSessionData, null, 2),
);
}

try {
const { paystackTxId } = paymentSessionData;

Expand All @@ -224,9 +323,16 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {
* Refunds payment for Paystack transaction
*/
async refundPayment(
paymentSessionData: Record<string, string>,
paymentSessionData: Record<string, unknown> & { paystackTxId: number },
refundAmount: number,
): Promise<Record<string, unknown> | PaymentProcessorError> {
if (this.debug) {
console.info(
"PS_P_Debug: RefundPayment",
JSON.stringify({ paymentSessionData, refundAmount }, null, 2),
);
}

try {
const { paystackTxId } = paymentSessionData;

Expand Down Expand Up @@ -256,6 +362,13 @@ class PaystackPaymentProcessor extends AbstractPaymentProcessor {
async getPaymentStatus(
paymentSessionData: Record<string, unknown> & { paystackTxId?: string },
): Promise<PaymentSessionStatus> {
if (this.debug) {
console.info(
"PS_P_Debug: GetPaymentStatus",
JSON.stringify(paymentSessionData, null, 2),
);
}

const { paystackTxId } = paymentSessionData;

if (!paystackTxId) {
Expand Down

0 comments on commit f41c216

Please sign in to comment.