@akinon/pz-tamara-extension 1.80.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.prettierrc ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "bracketSameLine": false,
3
+ "tabWidth": 2,
4
+ "singleQuote": true,
5
+ "jsxSingleQuote": false,
6
+ "bracketSpacing": true,
7
+ "semi": true,
8
+ "useTabs": false,
9
+ "arrowParens": "always",
10
+ "endOfLine": "lf",
11
+ "proseWrap": "never",
12
+ "trailingComma": "none"
13
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @akinon/pz-tamara-extension
2
+
3
+ ## 1.80.0-rc.7
4
+
5
+ ### Minor Changes
6
+
7
+ - 7ab9e2fd: ZERO-3166: add tamara payment package
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@akinon/pz-tamara-extension",
3
+ "version": "1.80.0-rc.7",
4
+ "license": "MIT",
5
+ "main": "src/index.tsx",
6
+ "peerDependencies": {
7
+ "react": "^18.0.0",
8
+ "react-dom": "^18.0.0"
9
+ },
10
+ "devDependencies": {
11
+ "@types/node": "^18.7.8",
12
+ "@types/react": "^18.0.17",
13
+ "@types/react-dom": "^18.0.6",
14
+ "prettier": "^3.0.3",
15
+ "react": "^18.2.0",
16
+ "react-dom": "^18.2.0",
17
+ "typescript": "^5.2.2"
18
+ }
19
+ }
package/readme.md ADDED
@@ -0,0 +1,138 @@
1
+ # Tamara Payment Gateway Extension
2
+
3
+ ## Installation
4
+
5
+ There are two ways to install the Tamara Payment Gateway extension:
6
+
7
+ ### 1. Install the npm package using Yarn
8
+
9
+ Before installing with Yarn, you need to add 'pz-tamara-extension' to your `plugins.js` file:
10
+
11
+ ```javascript
12
+ // plugins.js
13
+ module.exports = {
14
+ extensions: [
15
+ // ... other extensions
16
+ 'pz-tamara-extension'
17
+ ]
18
+ };
19
+ ```
20
+
21
+ Then, you can install the package using Yarn:
22
+
23
+ ```bash
24
+ yarn add @akinon/pz-tamara-extension
25
+ ```
26
+
27
+ ### 2. Preferred installation method
28
+
29
+ You can also use the following command to install the extension with the latest plugins:
30
+
31
+ ```bash
32
+ npx @akinon/projectzero@latest --plugins
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ Once the extension is installed, you can easily integrate the Tamara payment gateway into your application. Here's an example of how to use it.
38
+
39
+ 1. Navigate to the `src/app/[commerce]/[locale]/[currency]/payment-gateway/tamara/` directory.
40
+
41
+ 2. Create a file named `page.tsx` and include the following code:
42
+
43
+ ```jsx
44
+ import { TamaraPaymentGateway } from '@akinon/pz-tamara-extension';
45
+
46
+ const TamaraGateway = async ({
47
+ searchParams: { sessionId },
48
+ params: { currency, locale }
49
+ }: {
50
+ searchParams: Record<string, string>;
51
+ params: { currency: string; locale: string };
52
+ }) => {
53
+ return (
54
+ <TamaraPaymentGateway
55
+ sessionId={sessionId}
56
+ currency={currency}
57
+ locale={locale}
58
+ extensionUrl={process.env.TAMARA_EXTENSION_URL}
59
+ hashKey={process.env.TAMARA_HASH_KEY}
60
+ />
61
+ );
62
+ };
63
+
64
+ export default TamaraGateway;
65
+ ```
66
+
67
+ ## API Routes
68
+
69
+ ### Check Availability API
70
+
71
+ To enable Tamara payment availability checks, you need to create an API route. Create a file at `src/app/api/tamara-check-availability/route.ts` with the following content:
72
+
73
+ ```typescript
74
+ import { POST } from '@akinon/pz-tamara-extension/src/pages/api/check-availability';
75
+
76
+ export { POST };
77
+ ```
78
+
79
+ This API endpoint handles checking the availability of Tamara payment for a given:
80
+
81
+ - Country
82
+ - Phone number
83
+ - Order amount
84
+ - Currency
85
+
86
+ The endpoint automatically validates the request and response using hash-based security measures.
87
+
88
+ ### Using checkTamaraAvailability Mutation
89
+
90
+ The extension provides a Redux mutation hook that you can use to check Tamara payment availability. Here's an example of how to implement it:
91
+
92
+ ```typescript
93
+ import { useCheckTamaraAvailabilityMutation } from '@akinon/pz-tamara-extension/src/redux/api';
94
+
95
+ const YourComponent = () => {
96
+ const [checkTamaraAvailability] = useCheckTamaraAvailabilityMutation();
97
+ const [isTamaraAvailable, setIsTamaraAvailable] = useState(false);
98
+
99
+ useEffect(() => {
100
+ const checkAvailability = async () => {
101
+ try {
102
+ const response = await checkTamaraAvailability({
103
+ country: 'AE', // Country code
104
+ phone_number: '+971123456789', // Customer's phone number
105
+ order_amount: 1000 // Order total amount
106
+ }).unwrap();
107
+
108
+ setIsTamaraAvailable(response.has_availability);
109
+ } catch (error) {
110
+ console.error('Error checking Tamara availability:', error);
111
+ setIsTamaraAvailable(false);
112
+ }
113
+ };
114
+
115
+ checkAvailability();
116
+ }, [checkTamaraAvailability]);
117
+
118
+ // Use isTamaraAvailable to conditionally render Tamara payment option
119
+ return (
120
+ // Your component JSX
121
+ );
122
+ };
123
+ ```
124
+
125
+ The mutation returns an object with the following properties:
126
+
127
+ - `has_availability`: boolean indicating if Tamara payment is available
128
+ - `salt`: string used for hash verification
129
+ - `hash`: string for response validation
130
+
131
+ ## Configuration
132
+
133
+ Add these variables to your `.env` file
134
+
135
+ ```env
136
+ TAMARA_EXTENSION_URL=<your_extension_url>
137
+ TAMARA_HASH_KEY=<your_hash_key>
138
+ ```
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import { cookies } from 'next/headers';
3
+
4
+ type FormComponentProps = {
5
+ extensionUrl: string;
6
+ sessionId: string;
7
+ context: any;
8
+ };
9
+
10
+ const FormComponent = ({
11
+ extensionUrl,
12
+ sessionId,
13
+ context
14
+ }: FormComponentProps) => {
15
+ const nextCookies = cookies();
16
+
17
+ const extensionUrlWithoutSlash = `${extensionUrl}`.endsWith('/')
18
+ ? extensionUrl.slice(0, -1)
19
+ : extensionUrl;
20
+
21
+ const csrfToken = nextCookies.get('csrftoken')?.value ?? '';
22
+
23
+ return (
24
+ <form
25
+ action={`${extensionUrlWithoutSlash}/form-page/?sessionId=${sessionId}`}
26
+ method="post"
27
+ encType="multipart/form-data"
28
+ id="tamara-extension-form"
29
+ >
30
+ <input type="hidden" name="csrf_token" value={csrfToken} />
31
+ <input type="hidden" name="data" value={JSON.stringify(context)} />
32
+
33
+ <script
34
+ dangerouslySetInnerHTML={{
35
+ __html: "document.getElementById('tamara-extension-form').submit()"
36
+ }}
37
+ />
38
+ </form>
39
+ );
40
+ };
41
+
42
+ export default FormComponent;
package/src/index.tsx ADDED
@@ -0,0 +1 @@
1
+ export * from './pages/TamaraPaymentGateway';
@@ -0,0 +1,136 @@
1
+ import React from 'react';
2
+ import settings from 'settings';
3
+ import { cookies } from 'next/headers';
4
+ import FormComponent from '../components/FormComponent';
5
+ import {
6
+ fetchData,
7
+ generateHash,
8
+ getRandomString,
9
+ formatDecimal
10
+ } from '../utils';
11
+
12
+ type TamaraPaymentGatewayProps = {
13
+ sessionId: string;
14
+ currency: string;
15
+ locale: string;
16
+ extensionUrl: string;
17
+ hashKey: string;
18
+ };
19
+
20
+ export const TamaraPaymentGateway = async ({
21
+ sessionId,
22
+ currency,
23
+ locale,
24
+ extensionUrl,
25
+ hashKey
26
+ }: TamaraPaymentGatewayProps) => {
27
+ if (!sessionId || !currency || !locale) {
28
+ return <></>;
29
+ }
30
+
31
+ const nextCookies = cookies();
32
+
33
+ const language = settings.localization.locales.find(
34
+ (item) => item.value === locale
35
+ ).apiValue;
36
+
37
+ const requestHeaders = {
38
+ Cookie: `osessionid=${nextCookies.get('osessionid')?.value}`,
39
+ 'Content-Type': 'application/json',
40
+ 'X-Currency': currency,
41
+ 'X-Requested-With': 'XMLHttpRequest',
42
+ 'Accept-Language': language
43
+ };
44
+
45
+ const preOrder = await fetchData(
46
+ `${settings.commerceUrl}/orders/checkout/?page=OrderNotePage`,
47
+ requestHeaders
48
+ );
49
+
50
+ const salt = getRandomString(10);
51
+ const hash = generateHash(hashKey, sessionId, salt);
52
+
53
+ const unpaidAmount = Number(preOrder.pre_order.unpaid_amount || 0);
54
+ const shippingAmount = Math.min(
55
+ Number(preOrder.pre_order.shipping_amount || 0),
56
+ unpaidAmount
57
+ );
58
+
59
+ const unpaidAmountWithoutShipping = unpaidAmount - shippingAmount;
60
+ const basketItemSet = preOrder.pre_order.basket.basketitem_set;
61
+ const totalProductAmount = basketItemSet.reduce(
62
+ (sum: number, item: any) => sum + Number(item.total_amount),
63
+ 0
64
+ );
65
+ const remainingAmount = Math.max(
66
+ unpaidAmountWithoutShipping - totalProductAmount,
67
+ 0
68
+ );
69
+
70
+ let cumulativeAmount = 0;
71
+ let totalTaxAmount = 0;
72
+ const basketItems = basketItemSet.map((item: any, index: number) => {
73
+ const basketItemAmount = Number(item.total_amount);
74
+ const weight = basketItemAmount / totalProductAmount;
75
+ let amount = remainingAmount * weight + basketItemAmount;
76
+
77
+ cumulativeAmount += amount;
78
+
79
+ if (index === basketItemSet.length - 1) {
80
+ const delta = unpaidAmountWithoutShipping - cumulativeAmount;
81
+ amount += delta;
82
+ }
83
+
84
+ const taxRate = Number(item.tax_rate || 0) / 100;
85
+ totalTaxAmount += amount * taxRate;
86
+
87
+ return {
88
+ name: item.product?.name?.substring(0, 255) || 'none',
89
+ type: item.product?.category?.name,
90
+ reference_id: item.pk,
91
+ sku: item.product?.sku,
92
+ quantity: item.quantity,
93
+ total_amount: {
94
+ amount: formatDecimal(amount)
95
+ }
96
+ };
97
+ });
98
+
99
+ const context = {
100
+ hash,
101
+ salt,
102
+ tax_amount: {
103
+ amount: formatDecimal(totalTaxAmount)
104
+ },
105
+ shipping_amount: {
106
+ amount: formatDecimal(shippingAmount)
107
+ },
108
+ order_items: basketItems,
109
+ billing_address: {
110
+ city: preOrder.pre_order.billing_address.city.name,
111
+ country_code: preOrder.pre_order.billing_address.country.code,
112
+ first_name: preOrder.pre_order.billing_address.first_name,
113
+ last_name: preOrder.pre_order.billing_address.last_name,
114
+ line1: preOrder.pre_order.billing_address.line,
115
+ phone_number: preOrder.pre_order.billing_address.phone_number,
116
+ region: preOrder.pre_order.billing_address.township.name
117
+ },
118
+ shipping_address: {
119
+ city: preOrder.pre_order.shipping_address.city.name,
120
+ country_code: preOrder.pre_order.shipping_address.country.code,
121
+ first_name: preOrder.pre_order.shipping_address.first_name,
122
+ last_name: preOrder.pre_order.shipping_address.last_name,
123
+ line1: preOrder.pre_order.shipping_address.line,
124
+ phone_number: preOrder.pre_order.shipping_address.phone_number,
125
+ region: preOrder.pre_order.shipping_address.township.name
126
+ }
127
+ };
128
+
129
+ return (
130
+ <FormComponent
131
+ extensionUrl={extensionUrl}
132
+ sessionId={sessionId}
133
+ context={context}
134
+ />
135
+ );
136
+ };
@@ -0,0 +1,113 @@
1
+ import { NextResponse } from 'next/server';
2
+ import type { NextRequest } from 'next/server';
3
+ import crypto from 'crypto';
4
+ import { getRandomString, generateHash } from '../../utils';
5
+
6
+ interface CheckAvailabilityResponse {
7
+ salt: string;
8
+ hash: string;
9
+ has_availability: boolean;
10
+ }
11
+
12
+ const verifyResponseHash = (
13
+ salt: string,
14
+ hasAvailability: boolean,
15
+ hashKey: string,
16
+ responseHash: string
17
+ ): boolean => {
18
+ return (
19
+ generateHash(salt, hasAvailability ? 'True' : 'False', hashKey) ===
20
+ responseHash
21
+ );
22
+ };
23
+
24
+ export async function POST(request: NextRequest) {
25
+ try {
26
+ const hashKey = process.env.TAMARA_HASH_KEY;
27
+ const extensionUrl = process.env.TAMARA_EXTENSION_URL;
28
+
29
+ if (!hashKey) {
30
+ return NextResponse.json(
31
+ { message: 'TAMARA_HASH_KEY environment variable is not set' },
32
+ { status: 500 }
33
+ );
34
+ }
35
+
36
+ if (!extensionUrl) {
37
+ return NextResponse.json(
38
+ { message: 'TAMARA_EXTENSION_URL environment variable is not set' },
39
+ { status: 500 }
40
+ );
41
+ }
42
+
43
+ const body = await request.json();
44
+ const { country, phone_number, order_amount } = body;
45
+ const currency = request.cookies.get('pz-currency')?.value;
46
+
47
+ if (!currency) {
48
+ return NextResponse.json(
49
+ { message: 'Currency not found in cookies' },
50
+ { status: 400 }
51
+ );
52
+ }
53
+
54
+ if (!country || !phone_number || !order_amount) {
55
+ return NextResponse.json(
56
+ { message: 'Missing required fields' },
57
+ { status: 400 }
58
+ );
59
+ }
60
+
61
+ const upperCountry = country.toUpperCase();
62
+ const upperCurrency = currency.toUpperCase();
63
+
64
+ const salt = getRandomString(10);
65
+ const hash = generateHash(salt, upperCountry, phone_number, hashKey);
66
+
67
+ const response = await fetch(`${extensionUrl}/check-availability`, {
68
+ method: 'POST',
69
+ headers: {
70
+ 'Content-Type': 'application/json'
71
+ },
72
+ body: JSON.stringify({
73
+ salt,
74
+ hash,
75
+ country: upperCountry,
76
+ phone_number,
77
+ order_value: {
78
+ amount: order_amount,
79
+ currency: upperCurrency
80
+ }
81
+ })
82
+ });
83
+
84
+ if (!response.ok) {
85
+ const errorData = await response.json();
86
+ return NextResponse.json(errorData, { status: response.status });
87
+ }
88
+
89
+ const data: CheckAvailabilityResponse = await response.json();
90
+
91
+ const isValidHash = verifyResponseHash(
92
+ data.salt,
93
+ data.has_availability,
94
+ hashKey,
95
+ data.hash
96
+ );
97
+
98
+ if (!isValidHash) {
99
+ return NextResponse.json(
100
+ { message: 'Invalid response hash' },
101
+ { status: 400 }
102
+ );
103
+ }
104
+
105
+ return NextResponse.json(data);
106
+ } catch (error) {
107
+ console.error('Error checking Tamara availability:', error);
108
+ return NextResponse.json(
109
+ { message: 'Internal server error' },
110
+ { status: 500 }
111
+ );
112
+ }
113
+ }
@@ -0,0 +1,36 @@
1
+ import { api } from '@akinon/next/data/client/api';
2
+ import { setPaymentStepBusy } from '@akinon/next/redux/reducers/checkout';
3
+
4
+ interface CheckTamaraAvailabilityRequest {
5
+ country: string;
6
+ phone_number: string;
7
+ order_amount: string;
8
+ }
9
+
10
+ interface CheckTamaraAvailabilityResponse {
11
+ salt: string;
12
+ hash: string;
13
+ has_availability: boolean;
14
+ }
15
+
16
+ export const tamaraApi = api.injectEndpoints({
17
+ endpoints: (build) => ({
18
+ checkTamaraAvailability: build.mutation<
19
+ CheckTamaraAvailabilityResponse,
20
+ CheckTamaraAvailabilityRequest
21
+ >({
22
+ query: (body) => ({
23
+ url: '/api/tamara-check-availability',
24
+ method: 'POST',
25
+ body
26
+ }),
27
+ async onQueryStarted(arg, { dispatch, queryFulfilled }) {
28
+ dispatch(setPaymentStepBusy(true));
29
+ await queryFulfilled;
30
+ dispatch(setPaymentStepBusy(false));
31
+ }
32
+ })
33
+ })
34
+ });
35
+
36
+ export const { useCheckTamaraAvailabilityMutation } = tamaraApi;
@@ -0,0 +1,28 @@
1
+ import crypto from 'crypto';
2
+
3
+ export const fetchData = async (url: string, requestHeaders: HeadersInit) => {
4
+ const response = await fetch(url, { headers: requestHeaders });
5
+ return response.json();
6
+ };
7
+
8
+ export const getRandomString = (length: number): string => {
9
+ const characters =
10
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
11
+ return Array.from({ length }, () =>
12
+ characters.charAt(Math.floor(Math.random() * characters.length))
13
+ ).join('');
14
+ };
15
+
16
+ export const generateHash = (...values: string[]): string => {
17
+ const hashStr = values.join('|');
18
+ return crypto.createHash('sha512').update(hashStr, 'utf8').digest('hex');
19
+ };
20
+
21
+ export const formatDecimal = (value: number): string => {
22
+ const decimalStr = value.toString();
23
+ const decimalPlaces = decimalStr.includes('.')
24
+ ? decimalStr.split('.')[1].length
25
+ : 0;
26
+
27
+ return value.toFixed(decimalPlaces);
28
+ };