Repository: litespace-org/litespace
Pull request #608: impl: server handlers for fawry; types and atlas associated files have been implemeneted as well.
Author: login: mmoehabb
Created: 2025-04-14T10:07:33Z | Merged: 2025-04-16T10:45:23Z

### Quality Checklist

<!-- If one or more checklist items doesn't apply to this pull request, consider removing it from the pull request description  -->

<!-- Learn about self-review by reading this article: https://thundergolfer.com/pr-self-review -->

- [x] I ensured that the this pull request satisfies all the requirements in the associated task.
- [x] I performed a self-review.

---

<!-- Attach the the task link. Remove it incase no associated task. -->

[ClickUp Task 1](https://app.clickup.com/t/8698mag24)
[ClickUp Task 2](https://app.clickup.com/t/8698makfb)



Diff:
diff --git a/apps/web/.env.staging b/apps/web/.env.staging
index 2478a2107..7b398150c 100644
--- a/apps/web/.env.staging
+++ b/apps/web/.env.staging
@@ -11,3 +11,4 @@ VITE_SENTRY_DSN="https://ce4bff3cccc54065c32cdddc3d442ff7@o4508330010869760.inge
 SENTRY_PROJECT_NAME="litespace-web-staging"
 VITE_GA_MEASUREMENT_ID="G-J2Z5N1XV3E"
 VITE_CLARITY_PROJECT_ID="qpc96w85wn"
+FAWRY_ACCOUNT_NUMBER=""
diff --git a/apps/web/src/components/Plans/Content.tsx b/apps/web/src/components/Plans/Content.tsx
index 59f3f42fe..838a5c414 100644
--- a/apps/web/src/components/Plans/Content.tsx
+++ b/apps/web/src/components/Plans/Content.tsx
@@ -53,6 +53,7 @@ const Content: React.FC<{
 
   const notifyComingSoon = useCallback(() => {
     toast.success({
+      id: "notify-comming-soon",
       title: intl("plans.subscribe.soon.title"),
       description: intl("plans.subscribe.soon.description"),
     });
diff --git a/apps/web/src/lib/env.ts b/apps/web/src/lib/env.ts
index 48be8657b..cbe86d906 100644
--- a/apps/web/src/lib/env.ts
+++ b/apps/web/src/lib/env.ts
@@ -18,6 +18,7 @@ const schema = zod.object({
   sentryDsn: zod.string().url(),
   gaMeasurementId: zod.string().startsWith("G-").length(12),
   clarityProjectId: zod.string().length(10),
+  fawryAccountNumber: zod.string(),
 });
 
 export const env = schema.parse({
@@ -27,4 +28,5 @@ export const env = schema.parse({
   sentryDsn: import.meta.env.VITE_SENTRY_DSN,
   gaMeasurementId: import.meta.env.VITE_GA_MEASUREMENT_ID,
   clarityProjectId: import.meta.env.VITE_CLARITY_PROJECT_ID,
+  fawryAccountNumber: import.meta.env.VITE_FAWRY_ACCOUNT_NUMBER,
 });
diff --git a/apps/web/src/lib/fawry.ts b/apps/web/src/lib/fawry.ts
new file mode 100644
index 000000000..461e54eb6
--- /dev/null
+++ b/apps/web/src/lib/fawry.ts
@@ -0,0 +1,17 @@
+import { env } from "@/lib/env";
+import { router } from "@/lib/routes";
+import { Web } from "@litespace/utils/routes";
+
+/**
+ * read: https://developer.fawrystaging.com/docs/card-tokens/create-use-token#tooltip-webclient-token
+ */
+export function getAddCardIntegrationURL(userId: number, returnUrl?: string) {
+  const accNo = env.fawryAccountNumber;
+  const url = returnUrl || router.web({ route: Web.Root, full: true });
+  const base =
+    env.client === "production"
+      ? "https://www.atfawry.com/atfawry/plugin/card-token"
+      : "https://atfawry.fawrystaging.com/atfawry/plugin/card-token";
+  const params = `?accNo=${accNo}&customerProfileId=${userId}&returnUrl=${url}`;
+  return base.concat(params);
+}
diff --git a/package.json b/package.json
index 8242c33c3..ca41e2134 100644
--- a/package.json
+++ b/package.json
@@ -109,5 +109,5 @@
       "protobufjs"
     ]
   },
-  "packageManager": "pnpm@10.8.0+sha256.29bf2c5ceaea7991ee82eec15fe7162e0fad816d0c4a6b35a16c01d39274bf69"
+  "packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
 }
diff --git a/packages/atlas/src/api/fawry.ts b/packages/atlas/src/api/fawry.ts
new file mode 100644
index 000000000..5e1b43ce5
--- /dev/null
+++ b/packages/atlas/src/api/fawry.ts
@@ -0,0 +1,59 @@
+import { Base } from "@/lib/base";
+import { IFawry } from "@litespace/types";
+
+export class Fawry extends Base {
+  public async payWithCard(
+    payload: IFawry.PayWithCardPayload
+  ): Promise<IFawry.PayWithCardResponse> {
+    return await this.post({ route: "/api/v1/fawry/pay/card-token", payload });
+  }
+
+  public async payWithRefNum(
+    payload: IFawry.PayWithRefNumPayload
+  ): Promise<IFawry.PayWithRefNumResponse> {
+    return await this.post({ route: "/api/v1/fawry/pay/ref-num", payload });
+  }
+
+  public async payWithEWallet(
+    payload: IFawry.PayWithEWalletPayload
+  ): Promise<IFawry.PayWithEWalletResponse> {
+    return await this.post({ route: "/api/v1/fawry/pay/e-wallet", payload });
+  }
+
+  public async payWithBankInstallments(
+    payload: IFawry.PayWithBankInstallmentsPayload
+  ): Promise<IFawry.PayWithBankInstallmentsResponse> {
+    return await this.post({
+      route: "/api/v1/fawry/pay/bank-installments",
+      payload,
+    });
+  }
+
+  public async cancelUnpaidOrder(
+    payload: IFawry.CancelUnpaidOrderPayload
+  ): Promise<IFawry.CancelUnpaidOrderResponse> {
+    return await this.post({
+      route: "/api/v1/fawry/cancel-unpaid-order",
+      payload,
+    });
+  }
+
+  public async refund(
+    payload: IFawry.RefundPayload
+  ): Promise<IFawry.RefundResponse> {
+    return await this.post({ route: "/api/v1/fawry/refund", payload });
+  }
+
+  public async listCardTokens(): Promise<IFawry.ListCardTokensResponse> {
+    return await this.get({ route: "/api/v1/fawry/card-token/list" });
+  }
+
+  public async deleteCardToken(
+    payload: IFawry.DeleteCardTokenPayload
+  ): Promise<IFawry.DeleteCardTokenResponse> {
+    return await this.del({
+      route: "/api/v1/fawry/card-token/delete",
+      payload,
+    });
+  }
+}
diff --git a/packages/atlas/src/api/index.ts b/packages/atlas/src/api/index.ts
index 1b8897a99..50da5a6b0 100644
--- a/packages/atlas/src/api/index.ts
+++ b/packages/atlas/src/api/index.ts
@@ -19,6 +19,7 @@ import { AuthToken, createClient } from "@/lib/client";
 import { Cache } from "@/api/cache";
 import { Session } from "@/api/session";
 import { ContactRequest } from "@/api/contactRequest";
+import { Fawry } from "@/api/fawry";
 
 export class Api {
   public readonly user: User;
@@ -40,6 +41,7 @@ export class Api {
   public readonly cache: Cache;
   public readonly topic: Topic;
   public readonly session: Session;
+  public readonly fawry: Fawry;
 
   constructor(server: Env.Server, token: AuthToken | null) {
     const client = createClient("api", server, token);
@@ -62,5 +64,6 @@ export class Api {
     this.cache = new Cache(client);
     this.topic = new Topic(client);
     this.session = new Session(client);
+    this.fawry = new Fawry(client);
   }
 }
diff --git a/packages/types/src/fawry.ts b/packages/types/src/fawry.ts
new file mode 100644
index 000000000..1f09ccbf7
--- /dev/null
+++ b/packages/types/src/fawry.ts
@@ -0,0 +1,177 @@
+type GeneralResponse = {
+  /**
+   * Fawry Api response status code
+   */
+  statusCode: number;
+  /**
+   * Fawry Api response status description
+   */
+  statusDescription: string;
+};
+
+/**
+ * this's defined by fawry docs (cannot be changed or extended)
+ * https://developer.fawrystaging.com/docs/card-tokens/create-use-token#key_value_list
+ */
+export type OrderStatus =
+  | "NEW"
+  | "PAID"
+  | "CANCELED"
+  | "REFUNDED"
+  | "EXPIRED"
+  | "PARTIAL_REFUNDED"
+  | "FAILED";
+
+export type PayWithCardPayload = {
+  amount: number;
+  cardToken: string;
+  cvv: number;
+  returnUrl: string;
+};
+
+export type PayWithCardResponse = GeneralResponse & {
+  transactionId: number;
+  redirectUrl: string;
+};
+
+export type PayWithRefNumPayload = {
+  amount: number;
+  /**
+   * ISO datetime
+   */
+  paymentExpirey?: string;
+};
+
+export type PayWithRefNumResponse = GeneralResponse & {
+  transactionId: number;
+  orderStatus: OrderStatus;
+  /**
+   * Order amount in two decimal places format.
+   */
+  orderAmount: number;
+  /**
+   * The paid amount in two decimal places format.
+   */
+  paymentAmount: number;
+  /**
+   * The payment processing fees.
+   */
+  fawryFees: number;
+  /**
+   * Timestamp to record when the payment has been processed.
+   * Example: 1607879720568
+   */
+  paymentTime: number;
+};
+
+export type PayWithEWalletPayload = {
+  amount: number;
+  /**
+   * ISO datetime
+   */
+  paymentExpirey?: string;
+};
+
+export type PayWithEWalletResponse = GeneralResponse & {
+  transactionId: number;
+  /**
+   * The reference number of the order on FawryPay system which is displayed to
+   * the customer and used during the payment.
+   * Example: 100162801
+   */
+  orderRefNum: string;
+  /**
+   * Base 64 encoded PNG QR code image.
+   * Example: "data:image/PNG;base64,iVBORw0KGgoAAAANSUhEUgA....=="
+   */
+  walletQr: string;
+};
+
+export type PayWithBankInstallmentsPayload = {
+  planId: number;
+  amount: number;
+  cardToken: string;
+  cvv: number;
+  returnUrl: string;
+};
+
+export type PayWithBankInstallmentsResponse = GeneralResponse & {
+  transactionId: number;
+  /**
+   * Payment authentication number.
+   * Example: 12336534
+   */
+  authNumber: string;
+  /**
+   * Order amount in two decimal places format.
+   */
+  orderAmount: number;
+  /**
+   * The paid amount in two decimal places format.
+   */
+  paymentAmount: number;
+  /**
+   * The payment processing fees.
+   */
+  fawryFees: number;
+  /**
+   * Timestamp to record when the payment has been processed.
+   * Example: 1607879720568
+   */
+  paymentTime: number;
+};
+
+export type CancelUnpaidOrderPayload = {
+  /**
+   * The customer order reference number.
+   */
+  orderRefNum: string;
+};
+
+export type CancelUnpaidOrderResponse = GeneralResponse & {
+  reason: string;
+};
+
+export type RefundPayload = {
+  /**
+   * The customer order reference number.
+   */
+  orderRefNum: string;
+  /**
+   * The required amount to be refunded.
+   */
+  refundAmount: number;
+  /**
+   * The reason of the refund.
+   */
+  reason?: string;
+};
+
+export type RefundResponse = GeneralResponse & {};
+
+export type ListCardTokensResponse = GeneralResponse & {
+  cards: Array<{
+    /**
+     * The saved card token for your requested client ID.
+     */
+    token: string;
+    /**
+     * Timestamp of the token creation date.
+     */
+    creationDate: number;
+    /**
+     * The last four digits of the card
+     */
+    lastFourDigits: number;
+    /**
+     * The card issuer brand name
+     */
+    brand: string;
+  }>;
+};
+
+export type DeleteCardTokenPayload = {
+  cardToken: string;
+};
+
+export type DeleteCardTokenResponse = GeneralResponse & {};
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 81592c1a8..45257cae0 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -32,6 +32,7 @@ export * as IEcho from "@/echo";
 export * as IShortUrl from "@/ShortUrl";
 export * as IKafka from "@/kafka";
 export * as IMessenger from "@/messenger";
+export * as IFawry from "@/fawry";
 
 export * from "@/api";
 export * from "@/utils";
diff --git a/services/server/.env.example b/services/server/.env.example
index 9a77d2872..e6d6bc49e 100644
--- a/services/server/.env.example
+++ b/services/server/.env.example
@@ -22,3 +22,6 @@ TELEGRAM_TOKEN=""
 TELEGRAM_CHAT="0"
 
 GHOST_PASSWORD='pass'
+
+FAWRY_MERCHANT_CODE=""
+FAWRY_SECURE_KEY=""
diff --git a/services/server/.env.test b/services/server/.env.test
index 83f6aaea9..7a8ae442c 100644
--- a/services/server/.env.test
+++ b/services/server/.env.test
@@ -22,3 +22,6 @@ TELEGRAM_TOKEN=""
 TELEGRAM_CHAT="0"
 
 GHOST_PASSWORD='pass'
+
+FAWRY_MERCHANT_CODE=""
+FAWRY_SECURE_KEY=""
diff --git a/services/server/.env.test.ci b/services/server/.env.test.ci
index 3b4eaabb1..8eeed7907 100644
--- a/services/server/.env.test.ci
+++ b/services/server/.env.test.ci
@@ -22,3 +22,6 @@ TELEGRAM_TOKEN=""
 TELEGRAM_CHAT="0"
 
 GHOST_PASSWORD='pass'
+
+FAWRY_MERCHANT_CODE=""
+FAWRY_SECURE_KEY=""
diff --git a/services/server/package.json b/services/server/package.json
index 93c4f3625..f2679bfa1 100644
--- a/services/server/package.json
+++ b/services/server/package.json
@@ -61,11 +61,11 @@
     "@aws-sdk/s3-request-presigner": "^3.782.0",
     "@litespace/auth": "workspace:^",
     "@litespace/emails": "workspace:^",
+    "@litespace/kafka": "workspace:^",
     "@litespace/models": "workspace:^",
     "@litespace/radio": "workspace:^",
     "@litespace/types": "workspace:^",
     "@litespace/utils": "workspace:^",
-    "@litespace/kafka": "workspace:^",
     "@types/multer": "^1.4.12",
     "axios": "^1.6.8",
     "body-parser": "^1.20.2",
diff --git a/services/server/src/constants.ts b/services/server/src/constants.ts
index e69a16631..b7ae2434e 100644
--- a/services/server/src/constants.ts
+++ b/services/server/src/constants.ts
@@ -150,3 +150,8 @@ export const telegramConfig = {
     .number({ message: "Missing telegram chat id" })
     .parse(Number(process.env.TELEGRAM_CHAT)),
 };
+
+export const fawryConfig = {
+  merchantCode: zod.string().parse(process.env.FAWRY_MERCHANT_CODE),
+  secureKey: zod.string().parse(process.env.FAWRY_SECURE_KEY),
+};
diff --git a/services/server/src/fawry/api.ts b/services/server/src/fawry/api.ts
index 69a94e5fe..b445c0f58 100644
--- a/services/server/src/fawry/api.ts
+++ b/services/server/src/fawry/api.ts
@@ -95,4 +95,4 @@ class Api extends Base {
   }
 }
 
-export const FawryApi = new Api(createClient(environment));
+export const fawryApi = new Api(createClient(environment));
diff --git a/services/server/src/fawry/lib/index.ts b/services/server/src/fawry/lib/index.ts
index 0c7141b4a..095695f2f 100644
--- a/services/server/src/fawry/lib/index.ts
+++ b/services/server/src/fawry/lib/index.ts
@@ -1 +1 @@
-export * as signature from "@/fawry/lib/signature";
+export * as genSignature from "@/fawry/lib/signature";
diff --git a/services/server/src/fawry/lib/signature.ts b/services/server/src/fawry/lib/signature.ts
index bbff889f8..f0df1c0a1 100644
--- a/services/server/src/fawry/lib/signature.ts
+++ b/services/server/src/fawry/lib/signature.ts
@@ -1,54 +1,165 @@
+import { fawryConfig } from "@/constants";
 import {
   CancelPaymentAuthPayload,
   CancelUnpaidOrderPayload,
   CapturePaymentPayload,
-  DeleteCardTokenPayload,
-  GeneralRequestPayload,
-  ListCardTokensPayload,
+  BaseRequestPayload,
   RefundPayload,
+  PayWithRefNumPayload,
+  PayWithEWalletPayload,
+  PayWithCardAndBankInstallmentsPayload,
 } from "@/fawry/types/requests";
+import { createHash } from "node:crypto";
+import { PaymentMethod } from "@/fawry/types/ancillaries";
 
-function generateSignature(...data: unknown[]) {
-  let dataString = "";
-  for (const value of data) {
-    dataString += value || "";
-  }
-  return btoa(dataString);
+function generateSignature(...data: Array<string | number | undefined>) {
+  const payload = data
+    .map((value) => value?.toString())
+    .filter((value) => !!value)
+    .join("");
+  return createHash("sha256").update(payload).digest("hex");
 }
+
 /**
- * The SHA-256 digested for the following concatenated string "merchantCode + merchantRefNum +
+ * The SHA-256 digested for the following concatenated string: "merchantCode + merchantRefNum +
  * customerProfileId (if exists, otherwise "") + paymentMethod + amount (in two decimal format
- * 10.00) + cardNumber + cardExpiryYear + cardExpiryMonth + cvv + returnUrl + secureKey"
+ * 10.00) + (cardNumber + cardExpiryYear + cardExpiryMonth or just, cardToken) + cvv + returnUrl + secureKey"
  */
-export function forGeneralRequest(
+export function forPayWithCard(
   data: {
-    secureKey: string;
-    cardNumber?: number;
-    cardExpiryYear?: number;
-    cardExpiryMonth?: number;
-    cvv?: number;
-    returnUrl?: string;
-  } & Pick<
-    GeneralRequestPayload,
-    | "merchantCode"
-    | "merchantRefNum"
-    | "customerProfileId"
-    | "paymentMethod"
-    | "amount"
-  >
+    merchantRefNum: BaseRequestPayload["merchantRefNum"];
+    customerProfileId: BaseRequestPayload["customerProfileId"];
+    amount: BaseRequestPayload["amount"];
+    cvv: number;
+    returnUrl: string;
+  } & (
+    | { cardToken: string }
+    | {
+        cardNumber: string;
+        cardExpiryYear: string;
+        cardExpiryMonth: string;
+      }
+  )
 ): string {
+  const paymentMethod: PaymentMethod = "CARD";
+  const cardInfo =
+    "cardToken" in data
+      ? data.cardToken
+      : [data.cardNumber, data.cardExpiryYear, data.cardExpiryMonth].join("");
+
   return generateSignature(
-    data.merchantCode,
+    fawryConfig.merchantCode,
     data.merchantRefNum,
     data.customerProfileId,
-    data.paymentMethod,
-    data.amount,
-    data.cardNumber,
-    data.cardExpiryYear,
-    data.cardExpiryMonth,
+    paymentMethod,
+    data.amount.toFixed(2),
+    cardInfo,
     data.cvv,
     data.returnUrl,
-    data.secureKey
+    fawryConfig.secureKey
+  );
+}
+
+/**
+ * SHA-256 with the following order:
+ * 1. merchantCode
+ * 2. merchantRefNum
+ * 3. customerProfileId
+ * 4. payment ("PAYATFAWRY")
+ * 5. amount
+ * 6. secureKey
+ */
+export function forPayWithRefNum({
+  merchantRefNum,
+  customerProfileId,
+  amount,
+}: {
+  merchantRefNum: PayWithRefNumPayload["merchantRefNum"];
+  customerProfileId: PayWithRefNumPayload["customerProfileId"];
+  amount: PayWithRefNumPayload["amount"];
+}): string {
+  const paymentMethod: PaymentMethod = "PAYATFAWRY";
+  return generateSignature(
+    fawryConfig.merchantCode,
+    merchantRefNum,
+    customerProfileId,
+    paymentMethod,
+    amount.toFixed(2),
+    fawryConfig.secureKey
+  );
+}
+
+/**
+ * SHA-256 with the following order:
+ * 1. merchantCode
+ * 2. merchantRefNum
+ * 3. customerProfileId
+ * 4. payment ("MWALLET")
+ * 5. amount
+ * 6. secureKey
+ */
+export function forPayWithEWallet({
+  merchantRefNum,
+  customerProfileId,
+  amount,
+}: {
+  merchantRefNum: PayWithEWalletPayload["merchantRefNum"];
+  customerProfileId: PayWithEWalletPayload["customerProfileId"];
+  amount: PayWithEWalletPayload["amount"];
+}): string {
+  const paymentMethod: PaymentMethod = "MWALLET";
+  return generateSignature(
+    fawryConfig.merchantCode,
+    merchantRefNum,
+    customerProfileId,
+    paymentMethod,
+    amount.toFixed(2),
+    fawryConfig.secureKey
+  );
+}
+
+/**
+ * SHA-256 with the following order:
+ * 1. merchantCode
+ * 2. merchantRefNum
+ * 3. customerProfileId
+ * 4. payment ("CARD")
+ * 5. amount
+ * 7. cardToken (or cardNumber + cardExpiryYear + cardExpiryMonth)
+ * 6. secureKey
+ */
+export function forPayWithBankInstallment(
+  data: {
+    merchantRefNum: PayWithCardAndBankInstallmentsPayload["merchantRefNum"];
+    customerProfileId: PayWithCardAndBankInstallmentsPayload["customerProfileId"];
+    amount: PayWithCardAndBankInstallmentsPayload["amount"];
+    installmentPlanId: PayWithCardAndBankInstallmentsPayload["installmentPlanId"];
+    cvv: PayWithCardAndBankInstallmentsPayload["cvv"];
+  } & (
+    | { cardToken: string }
+    | {
+        cardNumber: string;
+        cardExpiryYear: string;
+        cardExpiryMonth: string;
+      }
+  )
+): string {
+  const paymentMethod: PaymentMethod = "CARD";
+  const cardInfo =
+    "cardToken" in data
+      ? data.cardToken
+      : [data.cardNumber, data.cardExpiryYear, data.cardExpiryMonth].join("");
+
+  return generateSignature(
+    fawryConfig.merchantCode,
+    data.merchantRefNum,
+    data.customerProfileId,
+    paymentMethod,
+    data.amount.toFixed(2),
+    cardInfo,
+    data.cvv,
+    data.installmentPlanId,
+    fawryConfig.secureKey
   );
 }
 
@@ -58,19 +169,14 @@ export function forGeneralRequest(
  * reason (if exists) + secureKey
  */
 export function forRefundRequest(
-  data: {
-    secureKey: string;
-  } & Pick<
-    RefundPayload,
-    "merchantCode" | "referenceNumber" | "refundAmount" | "reason"
-  >
+  data: Pick<RefundPayload, "referenceNumber" | "refundAmount" | "reason">
 ): string {
   return generateSignature(
-    data.merchantCode,
+    fawryConfig.merchantCode,
     data.referenceNumber,
     data.refundAmount,
     data.reason,
-    data.secureKey
+    fawryConfig.secureKey
   );
 }
 
@@ -78,15 +184,11 @@ export function forRefundRequest(
  * The SHA-256 digested for the following concatenated string merchantCode +
  * customerProfileId + secureKey
  */
-export function forListCardTokensRequest(
-  data: {
-    secureKey: string;
-  } & Pick<ListCardTokensPayload, "merchantCode" | "customerProfileId">
-): string {
+export function forListCardTokensRequest(customerProfileId: number): string {
   return generateSignature(
-    data.merchantCode,
-    data.customerProfileId,
-    data.secureKey
+    fawryConfig.merchantCode,
+    customerProfileId,
+    fawryConfig.secureKey
   );
 }
 
@@ -94,15 +196,11 @@ export function forListCardTokensRequest(
  * The SHA-256 digested for the following concatenated string merchantCode +
  * customerProfileId + secureKey
  */
-export function forDeleteCardTokenRequest(
-  data: {
-    secureKey: string;
-  } & Pick<DeleteCardTokenPayload, "merchantCode" | "customerProfileId">
-): string {
+export function forDeleteCardTokenRequest(customerProfileId: number): string {
   return generateSignature(
-    data.merchantCode,
-    data.customerProfileId,
-    data.secureKey
+    fawryConfig.merchantCode,
+    customerProfileId,
+    fawryConfig.secureKey
   );
 }
 
@@ -111,15 +209,16 @@ export function forDeleteCardTokenRequest(
  * (orderRefNo + merchantAccount + lang + secureKey
  */
 export function forCancelUnpaidOrderRequest(
-  data: {
-    secureKey: string;
-  } & Pick<CancelUnpaidOrderPayload, "orderRefNo" | "merchantAccount" | "lang">
+  data: Pick<
+    CancelUnpaidOrderPayload,
+    "orderRefNo" | "merchantAccount" | "lang"
+  >
 ): string {
   return generateSignature(
     data.orderRefNo,
     data.merchantAccount,
     data.lang,
-    data.secureKey
+    fawryConfig.secureKey
   );
 }
 
@@ -128,18 +227,13 @@ export function forCancelUnpaidOrderRequest(
  * (if exist in two decimal format 10.00, otherwise "") + merchantCode + secureKey"
  */
 export function forCapturePaymentRequest(
-  data: {
-    secureKey: string;
-  } & Pick<
-    CapturePaymentPayload,
-    "merchantRefNum" | "captureAmount" | "merchantCode"
-  >
+  data: Pick<CapturePaymentPayload, "merchantRefNum" | "captureAmount">
 ): string {
   return generateSignature(
     data.merchantRefNum,
     data.captureAmount,
-    data.merchantCode,
-    data.secureKey
+    fawryConfig.merchantCode,
+    fawryConfig.secureKey
   );
 }
 
@@ -147,13 +241,11 @@ export function forCapturePaymentRequest(
  * The SHA-256 digested for the following concatenated string "merchantRefNum + merchantCode + secureKey"
  */
 export function forCancelPaymentAuthRequest(
-  data: {
-    secureKey: string;
-  } & Pick<CancelPaymentAuthPayload, "merchantRefNum" | "merchantCode">
+  merchantRefNum: CancelPaymentAuthPayload["merchantRefNum"]
 ): string {
   return generateSignature(
-    data.merchantRefNum,
-    data.merchantCode,
-    data.secureKey
+    merchantRefNum,
+    fawryConfig.merchantCode,
+    fawryConfig.secureKey
   );
 }
diff --git a/services/server/src/fawry/types/errors.ts b/services/server/src/fawry/types/errors.ts
index 263a19368..5b23a7113 100644
--- a/services/server/src/fawry/types/errors.ts
+++ b/services/server/src/fawry/types/errors.ts
@@ -1,52 +1,207 @@
-export enum ErrorCode {
-  INVALID_MECHANT_CODE = 9901,
-  ORDER_NOT_FOUND = 9938,
-  INVALID_SIGNATURE = 9946,
-  REFUND_AMOUNT_EXCEEDED = 9935,
-  ORDER_NOT_PAID = 9954,
-  TRANSACTION_REFUSED = 99901 | 99905 | 99937 | 99939 | 99970 | 99971 | 99975,
-  TRANSACTION_INCORRECT = 99903,
-  CARD_LOST_OR_STOLEN = 99904,
-  FRAUD_CARD = 99906,
-  TRANSACTION_DECLINED = 99907,
-  TRANSACTION_FAILED = 99908 | 99910,
-  PROCESSING_CARD_ERROR = 99912,
-  TRANSACTION_INCORRECT_AMOUNT = 99913,
-  INVALID_CARD = 99914,
-  CARD_ISSUER_NOT_FOUND = 99915,
-  ISSUER_NOT_RESPONDING = 99922,
-  ACCOUNT_SETUP_ERROR = 99930,
-  CARD_NOT_ALLOWED_BY_BANK = 99931 | 99957,
-  FRAUD_CARD_SUSPECTED = 99934 | 99967,
-  LOST_CARD_NUMBER = 99941,
-  CARD_NOT_ALLOWED = 99942,
-  STOLEN_CARD = 99943,
-  ONLINE_TRANSACTIONS_NOT_ALLOWED = 99949,
-  INSUFFICIENT_FUNDS = 99951,
-  EXPIRED_CARD = 99954,
-  INCORRECT_PIN_CODE = 99955,
-  CARD_NOT_FOUND = 99956,
-  ACCOUNT_CONFIG_ERROR = 99958,
-  FRAUDULENT_TRANSACTION = 99959,
-  CARD_LIMIT_EXCEEDED = 99961 | 99965,
-  INVALID_CARD_IN_REGION = 99962,
-  SECURITY_CHECKS_VIOLATION = 99963,
-  TRANSACTION_ERROR = 99976 | 99977 | 99978 | 99979 | 99980 | 99981 | 99987,
-  TRANSACTION_ERROR_FROM_BANK = 99984,
-  NOT_ALLOWED_AMOUNT = 99985 | 21010,
-  BANK_TECHNICAL_ERROR = 99986 | 99988 | 99989,
-  BANK_CONTACTING_ERROR = 99991,
-  TRANSACTION_ROUTE_ERROR = 99992,
-  TECHNICAL_ERROR = 99994 | 99996,
-  UNAUTHORIZED_CARD = 55006,
-  INVALID_AMOUNT = 21004,
+export enum FawryStatusEnum {
+  Ok,
+  InvalidMechantCode,
+  OrderNotFound,
+  InvalidSignature,
+  RefundAmountExceeded,
+  OrderNotPaid,
+  TransactionRefused,
+  TransactionIncorrect,
+  CardLostOrStolen,
+  FraudCard,
+  TransactionDeclined,
+  TransactionFailed,
+  ProcessingCardError,
+  TransactionIncorrectAmount,
+  InvalidCard,
+  CardIssuerNotFound,
+  IssuerNotResponding,
+  AccountSetupError,
+  CardNotAllowedByBank,
+  FraudCardSuspected,
+  LostCardNumber,
+  CardNotAllowed,
+  StolenCard,
+  OnlineTransactionsNotAllowed,
+  InsufficientFunds,
+  ExpiredCard,
+  IncorrectPinCode,
+  CardNotFound,
+  AccountConfigError,
+  FraudulentTransaction,
+  CardLimitExceeded,
+  InvalidCardInRegion,
+  SecurityChecksViolation,
+  TransactionError,
+  TransactionErrorFromBank,
+  NotAllowedAmount,
+  BankTechnicalError,
+  BankContactingError,
+  TransactionRouteError,
+  TechnicalError,
+  UnauthorizedCard,
+  InvalidAmount,
 }
 
-export enum CancelPaymentErrorCode {
-  UNAUTHORIZED = 401,
-  ORDER_NOT_FOUND = 404 | 9938,
-  ORDER_ALREADY_PAID = 400,
-  ORDER_ALREADY_CANCELLED = 400 | 9984,
-  INVALID_MERCHANT_CODE = 9901,
-  INVALID_SIGNATURE = 9946,
+export type FawryStatusCode =
+  | 200
+  | 9901
+  | 9938
+  | 9946
+  | 9935
+  | 9954
+  | 99901
+  | 99905
+  | 99937
+  | 99939
+  | 99970
+  | 99971
+  | 99975
+  | 99903
+  | 99904
+  | 99906
+  | 99907
+  | 99908
+  | 99910
+  | 99912
+  | 99913
+  | 99914
+  | 99915
+  | 99922
+  | 99930
+  | 99931
+  | 99957
+  | 99934
+  | 99967
+  | 99941
+  | 99942
+  | 99943
+  | 99949
+  | 99951
+  | 99954
+  | 99955
+  | 99956
+  | 99958
+  | 99959
+  | 99961
+  | 99965
+  | 99962
+  | 99963
+  | 99976
+  | 99977
+  | 99978
+  | 99979
+  | 99980
+  | 99981
+  | 99987
+  | 99984
+  | 99985
+  | 21010
+  | 99986
+  | 99988
+  | 99989
+  | 99991
+  | 99992
+  | 99994
+  | 99996
+  | 55006
+  | 21004;
+
+export const FawryStatusMap: Record<FawryStatusCode, FawryStatusEnum> =
+  Object.freeze({
+    200: FawryStatusEnum.Ok,
+    9901: FawryStatusEnum.InvalidMechantCode,
+    9938: FawryStatusEnum.OrderNotFound,
+    9946: FawryStatusEnum.InvalidSignature,
+    9935: FawryStatusEnum.RefundAmountExceeded,
+    9954: FawryStatusEnum.OrderNotPaid,
+    99901: FawryStatusEnum.TransactionRefused,
+    99905: FawryStatusEnum.TransactionRefused,
+    99937: FawryStatusEnum.TransactionRefused,
+    99939: FawryStatusEnum.TransactionRefused,
+    99970: FawryStatusEnum.TransactionRefused,
+    99971: FawryStatusEnum.TransactionRefused,
+    99975: FawryStatusEnum.TransactionRefused,
+    99903: FawryStatusEnum.TransactionIncorrect,
+    99904: FawryStatusEnum.CardLostOrStolen,
+    99906: FawryStatusEnum.FraudCard,
+    99907: FawryStatusEnum.TransactionDeclined,
+    99908: FawryStatusEnum.TransactionFailed,
+    99910: FawryStatusEnum.TransactionFailed,
+    99912: FawryStatusEnum.ProcessingCardError,
+    99913: FawryStatusEnum.TransactionIncorrectAmount,
+    99914: FawryStatusEnum.InvalidCard,
+    99915: FawryStatusEnum.CardIssuerNotFound,
+    99922: FawryStatusEnum.IssuerNotResponding,
+    99930: FawryStatusEnum.AccountSetupError,
+    99931: FawryStatusEnum.CardNotAllowedByBank,
+    99957: FawryStatusEnum.CardNotAllowedByBank,
+    99934: FawryStatusEnum.FraudCardSuspected,
+    99967: FawryStatusEnum.FraudCardSuspected,
+    99941: FawryStatusEnum.LostCardNumber,
+    99942: FawryStatusEnum.CardNotAllowed,
+    99943: FawryStatusEnum.StolenCard,
+    99949: FawryStatusEnum.OnlineTransactionsNotAllowed,
+    99951: FawryStatusEnum.InsufficientFunds,
+    99954: FawryStatusEnum.ExpiredCard,
+    99955: FawryStatusEnum.IncorrectPinCode,
+    99956: FawryStatusEnum.CardNotFound,
+    99958: FawryStatusEnum.AccountConfigError,
+    99959: FawryStatusEnum.FraudulentTransaction,
+    99961: FawryStatusEnum.CardLimitExceeded,
+    99965: FawryStatusEnum.CardLimitExceeded,
+    99962: FawryStatusEnum.InvalidCardInRegion,
+    99963: FawryStatusEnum.SecurityChecksViolation,
+    99976: FawryStatusEnum.TransactionError,
+    99977: FawryStatusEnum.TransactionError,
+    99978: FawryStatusEnum.TransactionError,
+    99979: FawryStatusEnum.TransactionError,
+    99980: FawryStatusEnum.TransactionError,
+    99981: FawryStatusEnum.TransactionError,
+    99987: FawryStatusEnum.TransactionError,
+    99984: FawryStatusEnum.TransactionErrorFromBank,
+    99985: FawryStatusEnum.NotAllowedAmount,
+    21010: FawryStatusEnum.NotAllowedAmount,
+    99986: FawryStatusEnum.BankTechnicalError,
+    99988: FawryStatusEnum.BankTechnicalError,
+    99989: FawryStatusEnum.BankTechnicalError,
+    99991: FawryStatusEnum.BankContactingError,
+    99992: FawryStatusEnum.TransactionRouteError,
+    99994: FawryStatusEnum.TechnicalError,
+    99996: FawryStatusEnum.TechnicalError,
+    55006: FawryStatusEnum.UnauthorizedCard,
+    21004: FawryStatusEnum.InvalidAmount,
+  });
+
+export enum CancelPaymentErrorEnum {
+  Unauthorized,
+  OrderNotFound,
+  OrderAlreadyPaid,
+  OrderAlreadyCancelled,
+  InvalidMerchantCode,
+  InvalidSignature,
 }
+
+export type CancelPaymentErrorCode =
+  | 401
+  | 404
+  | 9938
+  | 400
+  | 9984
+  | 9901
+  | 9946;
+
+export const CancelPaymentErrorCode: Record<
+  CancelPaymentErrorCode,
+  CancelPaymentErrorEnum
+> = Object.freeze({
+  401: CancelPaymentErrorEnum.Unauthorized,
+  404: CancelPaymentErrorEnum.OrderNotFound,
+  9938: CancelPaymentErrorEnum.OrderNotFound,
+  400:
+    CancelPaymentErrorEnum.OrderAlreadyPaid |
+    CancelPaymentErrorEnum.OrderAlreadyCancelled,
+  9984: CancelPaymentErrorEnum.OrderAlreadyCancelled,
+  9901: CancelPaymentErrorEnum.InvalidMerchantCode,
+  9946: CancelPaymentErrorEnum.InvalidSignature,
+});
diff --git a/services/server/src/fawry/types/index.ts b/services/server/src/fawry/types/index.ts
index 4334f6ad1..98193c5c7 100644
--- a/services/server/src/fawry/types/index.ts
+++ b/services/server/src/fawry/types/index.ts
@@ -1,3 +1,2 @@
 export * as Requests from "@/fawry/types/requests";
 export * as Responses from "@/fawry/types/responses";
-export { ErrorCode, CancelPaymentErrorCode } from "@/fawry/types/errors";
diff --git a/services/server/src/fawry/types/requests.ts b/services/server/src/fawry/types/requests.ts
index bb3da7592..320440aba 100644
--- a/services/server/src/fawry/types/requests.ts
+++ b/services/server/src/fawry/types/requests.ts
@@ -4,39 +4,47 @@ import {
   PaymentMethod,
 } from "@/fawry/types/ancillaries";
 
-export type GeneralRequestPayload = {
-  // The merchant code provided by FawryPay team during the account setup.
+export type BaseRequestPayload = {
+  /**
+   * The merchant code provided by FawryPay team during the account setup.
+   */
   merchantCode: string;
-  // The unique reference number for the charge request in merchant system.
+  /**
+   * The unique reference number for the charge request in merchant system.
+   */
   merchantRefNum: number;
-  // The customer mobile in merchant system: 01xxxxxxx.
+  /**
+   * The customer mobile in merchant system: 01xxxxxxx.
+   */
   customerMobile: string;
-  // The customer e-mail in merchant system: test@email.com.
+  /**
+   * The customer e-mail in merchant system: test@email.com.
+   */
   customerEmail: string;
-  // The charge amount: must be in the form of xx.xx.
+  /**
+   * The charge amount: must be in the form of xx.xx.
+   */
   amount: number;
   currencyCode: CurrencyCode;
   language: Language;
-  chargeItems: [
-    {
-      /**
-       * The id for the charge item
-       */
-      itemId: string;
-      /**
-       * Description of charge item.
-       */
-      description: string;
-      /**
-       * Price per unit charge item.
-       */
-      price: number;
-      /**
-       * Quantity of the charge items.
-       */
-      quantity: number;
-    },
-  ];
+  chargeItems: Array<{
+    /**
+     * The id for the charge item
+     */
+    itemId: string;
+    /**
+     * Description of charge item.
+     */
+    description: string;
+    /**
+     * Price per unit charge item.
+     */
+    price: number;
+    /**
+     * Quantity of the charge items.
+     */
+    quantity: number;
+  }>;
   /**
    * The SHA-256 digested for the following concatenated string "merchantCode + merchantRefNum +
    * customerProfileId (if exists, otherwise "") + paymentMethod + amount (in two decimal format
@@ -60,7 +68,7 @@ export type GeneralRequestPayload = {
   orderWebHookUrl?: string;
 };
 
-export type PayWithCardPayload = GeneralRequestPayload & {
+export type PayWithCardPayload = BaseRequestPayload & {
   paymentMethod: "CARD";
   /**
    * Card cvv code.
@@ -107,7 +115,7 @@ export type PayWithCardPayload = GeneralRequestPayload & {
  */
 type Timestamp = number;
 
-export type PayWithEWalletPayload = GeneralRequestPayload & {
+export type PayWithEWalletPayload = BaseRequestPayload & {
   paymentMethod: "MWALLET";
   /**
    * Use this element to set specific expiration time for the generated order.
@@ -118,13 +126,13 @@ export type PayWithEWalletPayload = GeneralRequestPayload & {
   paymentExpiry?: Timestamp;
 };
 
-export type PayWithRefNumPayload = GeneralRequestPayload & {
+export type PayWithRefNumPayload = BaseRequestPayload & {
   paymentMethod: "PAYATFAWRY";
   /**
    * Use this element to set specific expiration time for the generated order.
-   * After this time, the QR shall expire and the client will not be able to
-   * confirm payment throuth it. This element take value as timestamp or in
-   * the format of date in milliseconds, e.g. 1631138400000.
+   * After this time, the received reference number shall expire and the client
+   * will not be able to pay using it. This element take value as timestamp or
+   * in the format of date in milliseconds, e.g. 1631138400000.
    */
   paymentExpiry?: Timestamp;
 };
diff --git a/services/server/src/fawry/types/responses.ts b/services/server/src/fawry/types/responses.ts
index f9bdddb72..875ed4301 100644
--- a/services/server/src/fawry/types/responses.ts
+++ b/services/server/src/fawry/types/responses.ts
@@ -3,7 +3,7 @@ import {
   PaymentMethod,
   VerStatus,
 } from "@/fawry/types/ancillaries";
-import { CancelPaymentErrorCode, ErrorCode } from "@/fawry/types/errors";
+import { FawryStatusCode } from "@/fawry/types/errors";
 
 type GeneralResponse = {
   /**
@@ -14,7 +14,7 @@ type GeneralResponse = {
   /**
    * The response status code.
    */
-  statusCode: ErrorCode;
+  statusCode: FawryStatusCode;
   /**
    * Exact description of the status of FawryPay response.
    */
@@ -252,7 +252,7 @@ export type ListCardTokensResponse = GeneralResponse & {
 export type DeleteCardTokenResponse = GeneralResponse & {};
 
 export type CancelUnpaidOrderResponse = {
-  code: CancelPaymentErrorCode;
+  code: number;
   description: string;
   reason: string;
 };
@@ -459,7 +459,7 @@ export type GetPaymentStatusResponse = {
    * Number of Months for the instalment
    */
   installmentMonths: number;
-  failureErrorCode: ErrorCode;
+  failureErrorCode: FawryStatusCode;
   failureReason: string;
   /**
    * The SHA-256 digested for the following concatenated string fawryRefNumber + merchantRefNum +
diff --git a/services/server/src/handlers/fawry.ts b/services/server/src/handlers/fawry.ts
new file mode 100644
index 000000000..f0420adbd
--- /dev/null
+++ b/services/server/src/handlers/fawry.ts
@@ -0,0 +1,392 @@
+import { NextFunction, Request, Response } from "express";
+import safeRequest from "express-async-handler";
+import zod from "zod";
+
+import { isRegularUser } from "@litespace/utils";
+import { IFawry } from "@litespace/types";
+
+import { fawryApi } from "@/fawry/api";
+import { bad, forbidden } from "@/lib/error";
+import { forgeFawryPayload } from "@/lib/fawry";
+import { genSignature } from "@/fawry/lib";
+import { fawryConfig } from "@/constants";
+import { datetime } from "@/validation/utils";
+import dayjs from "@/lib/dayjs";
+
+const payWithCardPayload = zod.object({
+  amount: zod.number(),
+  cardToken: zod.string(),
+  cvv: zod.number(),
+  returnUrl: zod.string(),
+});
+
+const payWithRefNumPayload = zod.object({
+  amount: zod.number(),
+  paymentExpirey: datetime.optional(),
+});
+
+const payWithEWalletPayload = zod.object({
+  amount: zod.number(),
+  paymentExpirey: datetime.optional(),
+});
+
+const payWithBankInstallmentsPayload = zod.object({
+  planId: zod.number(),
+  amount: zod.number(),
+  cardToken: zod.string(),
+  cvv: zod.number(),
+  returnUrl: zod.string(),
+});
+
+const cancelUnpaidOrderPayload = zod.object({
+  orderRefNum: zod.string(),
+});
+
+const refundPayload = zod.object({
+  orderRefNum: zod.string(),
+  refundAmount: zod.number(),
+  reason: zod.string().optional(),
+});
+
+const deleteCardTokenPayload = zod.object({
+  cardToken: zod.string(),
+});
+
+async function payWithCard(req: Request, res: Response, next: NextFunction) {
+  const user = req.user;
+  const allowed = isRegularUser(user);
+  if (!allowed) return next(forbidden());
+
+  const payload = payWithCardPayload.parse(req.body);
+
+  // TODO: store transaction in the database
+
+  const txId = Math.floor(Math.random() * 1000); // TODO: get transaction id from the db
+  const signature = genSignature.forPayWithCard({
+    amount: payload.amount,
+    cardToken: payload.cardToken,
+    cvv: payload.cvv,
+    returnUrl: "todo",
+    customerProfileId: user.id,
+    merchantRefNum: txId,
+  });
+
+  const forgedPayload = forgeFawryPayload({
+    merchantRefNum: txId,
+    paymentMethod: "CARD",
+    amount: payload.amount,
+    signature,
+    user,
+  });
+  if (forgedPayload instanceof Error) return next(bad());
+
+  const { nextAction, statusCode, statusDescription } =
+    await fawryApi.payWithCard({
+      ...forgedPayload,
+      paymentMethod: "CARD",
+      cardToken: payload.cardToken,
+      cvv: payload.cvv,
+      returnUrl: payload.returnUrl,
+      enable3DS: true,
+      authCaptureModePayment: true,
+    });
+
+  // TODO: store orderRefNumber in the transaction row
+
+  const response: IFawry.PayWithCardResponse = {
+    transactionId: forgedPayload.merchantRefNum,
+    redirectUrl: nextAction.redirectUrl,
+    statusCode,
+    statusDescription,
+  };
+
+  res.json(response);
+}
+
+async function payWithRefNum(req: Request, res: Response, next: NextFunction) {
+  const user = req.user;
+  const allowed = isRegularUser(user);
+  if (!allowed) return next(forbidden());
+
+  const payload = payWithRefNumPayload.parse(req.body);
+
+  // TODO: store transaction in the database
+  const txId = Math.floor(Math.random() * 1000);
+  const forgedPayload = forgeFawryPayload({
+    merchantRefNum: txId,
+    paymentMethod: "PAYATFAWRY",
+    amount: payload.amount,
+    signature: genSignature.forPayWithRefNum({
+      merchantRefNum: txId,
+      amount: payload.amount,
+      customerProfileId: user.id,
+    }),
+    user,
+  });
+
+  const {
+    orderStatus,
+    orderAmount,
+    paymentAmount,
+    fawryFees,
+    paymentTime,
+    statusCode,
+    statusDescription,
+  } = await fawryApi.payWithRefNum({
+    ...forgedPayload,
+    paymentMethod: "PAYATFAWRY",
+    paymentExpiry: payload.paymentExpirey
+      ? dayjs(payload.paymentExpirey).utc().unix()
+      : undefined,
+  });
+
+  // TODO: store orderRefNumber in the transaction row
+
+  const response: IFawry.PayWithRefNumResponse = {
+    transactionId: forgedPayload.merchantRefNum,
+    orderStatus,
+    orderAmount,
+    paymentAmount,
+    fawryFees,
+    paymentTime,
+    statusCode,
+    statusDescription,
+  };
+
+  res.json(response);
+}
+
+async function payWithEWallet(req: Request, res: Response, next: NextFunction) {
+  const user = req.user;
+  const allowed = isRegularUser(user);
+  if (!allowed) return next(forbidden());
+
+  const payload = payWithEWalletPayload.parse(req.body);
+
+  // TODO: store transaction in the database
+  const txId = Math.floor(Math.random() * 1000);
+
+  const forgedPayload = forgeFawryPayload({
+    merchantRefNum: txId,
+    paymentMethod: "MWALLET",
+    amount: payload.amount,
+    signature: genSignature.forPayWithEWallet({
+      merchantRefNum: txId,
+      amount: payload.amount,
+      customerProfileId: user.id,
+    }),
+    user,
+  });
+  if (forgedPayload instanceof Error) return next(bad());
+
+  const {
+    referenceNumber,
+    merchantRefNumber,
+    walletQr,
+    statusCode,
+    statusDescription,
+  } = await fawryApi.payWithEWallet({
+    ...forgedPayload,
+    paymentMethod: "MWALLET",
+    paymentExpiry: payload.paymentExpirey
+      ? dayjs(payload.paymentExpirey).utc().unix()
+      : undefined,
+  });
+
+  // TODO: store orderRefNumber in the transaction row
+
+  const response: IFawry.PayWithEWalletResponse = {
+    transactionId: Number(merchantRefNumber),
+    orderRefNum: referenceNumber,
+    walletQr,
+    statusCode,
+    statusDescription,
+  };
+
+  res.json(response);
+}
+
+async function payWithBankInstallments(
+  req: Request,
+  res: Response,
+  next: NextFunction
+) {
+  const user = req.user;
+  const allowed = isRegularUser(user);
+  if (!allowed) return next(forbidden());
+
+  const payload = payWithBankInstallmentsPayload.parse(req.body);
+
+  // TODO: store transaction in the database
+  const txId = Math.floor(Math.random() * 1000); // TODO: get transaction id from the db
+  const forgedPayload = forgeFawryPayload({
+    merchantRefNum: txId,
+    paymentMethod: "CARD",
+    amount: payload.amount,
+    user,
+    signature: genSignature.forPayWithBankInstallment({
+      merchantRefNum: txId,
+      amount: payload.amount,
+      cvv: payload.cvv,
+      cardToken: payload.cardToken,
+      customerProfileId: user.id,
+      installmentPlanId: 1,
+    }),
+  });
+  if (forgedPayload instanceof Error) return next(bad());
+
+  const {
+    merchantRefNumber,
+    authNumber,
+    orderAmount,
+    paymentAmount,
+    fawryFees,
+    paymentTime,
+    statusCode,
+    statusDescription,
+  } = await fawryApi.payWithBankInstallments({
+    ...forgedPayload,
+    paymentMethod: "CARD",
+    installmentPlanId: payload.planId,
+    cardToken: payload.cardToken,
+    cvv: payload.cvv,
+    returnUrl: payload.returnUrl,
+    enable3DS: true,
+    authCaptureModePayment: true,
+  });
+
+  // TODO: store orderRefNumber in the transaction row
+
+  const response: IFawry.PayWithBankInstallmentsResponse = {
+    transactionId: Number(merchantRefNumber),
+    authNumber,
+    orderAmount,
+    paymentAmount,
+    fawryFees,
+    paymentTime,
+    statusCode,
+    statusDescription,
+  };
+
+  res.json(response);
+}
+
+async function cancelUnpaidOrder(
+  req: Request,
+  res: Response,
+  next: NextFunction
+) {
+  const user = req.user;
+  const allowed = isRegularUser(user);
+  if (!allowed) return next(forbidden());
+
+  const payload = cancelUnpaidOrderPayload.parse(req.body);
+
+  const { code, description, reason } = await fawryApi.cancelUnpaidOrder({
+    merchantAccount: fawryConfig.merchantCode,
+    orderRefNo: payload.orderRefNum,
+    lang: "ar-eg",
+    signature: genSignature.forCancelUnpaidOrderRequest({
+      orderRefNo: "1",
+      lang: "ar-eg",
+      merchantAccount: fawryConfig.merchantCode,
+    }),
+  });
+
+  const response: IFawry.CancelUnpaidOrderResponse = {
+    statusCode: code,
+    statusDescription: description,
+    reason,
+  };
+
+  // TODO: update transaction in the database
+
+  res.json(response);
+}
+
+async function refund(req: Request, res: Response, next: NextFunction) {
+  const user = req.user;
+  const allowed = isRegularUser(user);
+  if (!allowed) return next(forbidden());
+
+  const payload = refundPayload.parse(req.body);
+
+  const { statusCode, statusDescription } = await fawryApi.refund({
+    merchantCode: fawryConfig.merchantCode,
+    referenceNumber: payload.orderRefNum,
+    refundAmount: payload.refundAmount,
+    reason: payload.reason,
+    signature: genSignature.forRefundRequest({
+      referenceNumber: payload.orderRefNum,
+      reason: payload.reason,
+      refundAmount: payload.refundAmount,
+    }),
+  });
+
+  const response: IFawry.RefundResponse = {
+    statusCode,
+    statusDescription,
+  };
+
+  // TODO: update transaction in the database
+
+  res.json(response);
+}
+
+async function listCardTokens(req: Request, res: Response, next: NextFunction) {
+  const user = req.user;
+  const allowed = isRegularUser(user);
+  if (!allowed) return next(forbidden());
+
+  const { cards, statusCode, statusDescription } =
+    await fawryApi.listCardTokens({
+      merchantCode: fawryConfig.merchantCode,
+      customerProfileId: user.id,
+      signature: genSignature.forListCardTokensRequest(user.id),
+    });
+
+  const response: IFawry.ListCardTokensResponse = {
+    cards,
+    statusCode,
+    statusDescription,
+  };
+
+  res.json(response);
+}
+
+async function deleteCardToken(
+  req: Request,
+  res: Response,
+  next: NextFunction
+) {
+  const user = req.user;
+  const allowed = isRegularUser(user);
+  if (!allowed) return next(forbidden());
+
+  const payload = deleteCardTokenPayload.parse(req.body);
+
+  const { statusCode, statusDescription } = await fawryApi.deleteCardToken({
+    merchantCode: fawryConfig.merchantCode,
+    customerProfileId: user.id,
+    cardToken: payload.cardToken,
+    signature: genSignature.forDeleteCardTokenRequest(user.id),
+  });
+
+  const response: IFawry.DeleteCardTokenResponse = {
+    statusCode,
+    statusDescription,
+  };
+
+  res.json(response);
+}
+
+export default {
+  payWithCard: safeRequest(payWithCard),
+  payWithRefNum: safeRequest(payWithRefNum),
+  payWithEWallet: safeRequest(payWithEWallet),
+  payWithBankInstallments: safeRequest(payWithBankInstallments),
+  cancelUnpaidOrder: safeRequest(cancelUnpaidOrder),
+  refund: safeRequest(refund),
+  listCardTokens: safeRequest(listCardTokens),
+  deleteCardToken: safeRequest(deleteCardToken),
+};
diff --git a/services/server/src/index.ts b/services/server/src/index.ts
index 84eed353b..7c379ab78 100644
--- a/services/server/src/index.ts
+++ b/services/server/src/index.ts
@@ -92,6 +92,7 @@ app.use("/api/v1/topic", routes.topic);
 app.use("/api/v1/withdraw-method/", routes.withdrawMethod);
 app.use("/api/v1/cache", routes.cache);
 app.use("/api/v1/session", routes.session);
+app.use("/api/v1/fawry", routes.fawry);
 app.use(errorHandler);
 
 server.listen(serverConfig.port, serverConfig.host, () =>
diff --git a/services/server/src/lib/fawry.ts b/services/server/src/lib/fawry.ts
new file mode 100644
index 000000000..83038a1d6
--- /dev/null
+++ b/services/server/src/lib/fawry.ts
@@ -0,0 +1,44 @@
+import { BaseRequestPayload } from "@/fawry/types/requests";
+import { PaymentMethod } from "@/fawry/types/ancillaries";
+import { fawryConfig } from "@/constants";
+import { IUser } from "@litespace/types";
+
+export function forgeFawryPayload({
+  merchantRefNum,
+  paymentMethod,
+  chargeItems = [],
+  description = "",
+  signature,
+  amount,
+  user,
+}: {
+  chargeItems?: BaseRequestPayload["chargeItems"];
+  paymentMethod: PaymentMethod;
+  merchantRefNum: number;
+  description?: string;
+  signature: string;
+  user: IUser.Self;
+  amount: number;
+}): BaseRequestPayload {
+  if (user.name === null || user.phone === null)
+    throw new Error(
+      "User name or phone number is missing; should never happen."
+    );
+
+  return {
+    merchantRefNum,
+    paymentMethod,
+    customerProfileId: user.id,
+    customerName: user.name,
+    customerMobile: user.phone,
+    customerEmail: user.email,
+    chargeItems,
+    description,
+    orderWebHookUrl: "/todo",
+    currencyCode: "EGP",
+    language: "ar-eg",
+    amount,
+    merchantCode: fawryConfig.merchantCode,
+    signature,
+  };
+}
diff --git a/services/server/src/routes/fawry.ts b/services/server/src/routes/fawry.ts
new file mode 100644
index 000000000..067fd5446
--- /dev/null
+++ b/services/server/src/routes/fawry.ts
@@ -0,0 +1,17 @@
+import { Router } from "express";
+import fawry from "@/handlers/fawry";
+
+const router = Router();
+
+router.post("/pay/card-token", fawry.payWithCard);
+router.post("/pay/ref-num", fawry.payWithRefNum);
+router.post("/pay/e-wallet", fawry.payWithEWallet);
+router.post("/pay/bank-installments", fawry.payWithBankInstallments);
+
+router.post("/cancel-unpaid-order", fawry.cancelUnpaidOrder);
+router.post("/refund", fawry.refund);
+
+router.get("/card-token/list", fawry.listCardTokens);
+router.delete("/card-token/delete", fawry.deleteCardToken);
+
+export default router;
diff --git a/services/server/src/routes/index.ts b/services/server/src/routes/index.ts
index b44bd169e..19f5f3f12 100644
--- a/services/server/src/routes/index.ts
+++ b/services/server/src/routes/index.ts
@@ -14,6 +14,7 @@ import topic from "@/routes/topic";
 import cache from "@/routes/cache";
 import session from "@/routes/session";
 import contactRequest from "@/routes/contactRequest";
+import fawry from "@/routes/fawry";
 
 export default {
   user,
@@ -32,4 +33,5 @@ export default {
   cache,
   session,
   availabilitySlot,
+  fawry,
 };


Reviews:
user: login: neuodev
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-15T06:36:09Z
user: login: mmoehabb
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T08:47:29Z
user: login: neuodev
body: LGTM! Well done @mmoehabb 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T10:39:25Z

Review comments:
diff_hunk: @@ -79,6 +79,7 @@
     "express-rate-limit": "^7.5.0",
     "express-session": "^1.18.0",
     "jsonwebtoken": "^9.0.2",
+    "jssha": "^3.3.1",
path: services/server/package.json
user: login: neuodev
body: We don't need this package. We can use the crypto node provided by nodejs. It is quite simple.

```ts
import { createHash } from 'node:crypto'

function sha256(content: string) {  
  return createHash('sha256').update(content).digest('hex')
}
```
created_at: 2025-04-15T06:00:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,16 @@
+import { Router } from "express";
+import fawry from "@/handlers/fawry";
+
+const router = Router();
+
+router.post("/pay/card-token", fawry.payWithCard);
+router.post("/pay/ref-num", fawry.payWithRefNum);
+router.post("/pay/e-wallet", fawry.payWithEWallet);
+router.post("/pay/bank-installments", fawry.payWithBankInstallments);
+
+router.post("/refund", fawry.refund);
+
+router.get("/card-token/list", fawry.listCardTokens);
+router.delete("/card-token/delete", fawry.deleteCardToken);
path: services/server/src/routes/fawry.ts
user: login: neuodev
body: We need an endpoint to cancel unpaid txs ([ref](https://developer.fawrystaging.com/docs/server-apis/cancel-payment-api))
created_at: 2025-04-15T06:04:08Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,47 @@
+import { GeneralRequestPayload } from "@/fawry/types/requests";
+import { PaymentMethod } from "@/fawry/types/ancillaries";
+import { fawryConfig } from "@/constants";
+import { IUser } from "@litespace/types";
+
+export function forgeFawryPayload<Other = object>({
path: services/server/src/lib/fawry.ts
user: login: neuodev
body: I am not a fan of this function for two reasons: 
1. It is using casting. 
2. It accepts the signature as an optional param with default value set to empty string (which is not true and invalid). Also, you relay on the consumer of this function to re-assign the value the signature.
created_at: 2025-04-15T06:10:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,47 @@
+import { GeneralRequestPayload } from "@/fawry/types/requests";
+import { PaymentMethod } from "@/fawry/types/ancillaries";
+import { fawryConfig } from "@/constants";
+import { IUser } from "@litespace/types";
+
+export function forgeFawryPayload<Other = object>({
+  merchantRefNum,
+  amount,
+  paymentMethod,
+  signature,
+  user,
+  chargeItems,
+  description,
+  orderWebHookUrl,
+  other,
+}: {
+  merchantRefNum: number;
+  amount: number;
+  paymentMethod: PaymentMethod;
+  signature?: string;
+  user?: IUser.Self;
+  chargeItems?: GeneralRequestPayload["chargeItems"];
+  description?: string;
+  orderWebHookUrl?: string;
+  other?: Other;
+}) {
+  return {
+    merchantRefNum,
+    paymentMethod,
+    amount,
+    signature: signature || "",
+
+    customerProfileId: user?.id,
+    customerName: user?.name,
+    customerMobile: user?.phone,
+    customerEmail: user?.email,
path: services/server/src/lib/fawry.ts
user: login: neuodev
body: The phone and the email is required as estated in the fawry docs. You are don't getting any errors about it because you are using casting. 
created_at: 2025-04-15T06:11:16Z
author_association: MEMBER
diff_hunk: @@ -1,3 +1,4 @@
+import jsSHA from "jssha";
path: services/server/src/fawry/lib/signature.ts
user: login: neuodev
body: Let's use the crypto module from nodejs. This package is un-needed. 
created_at: 2025-04-15T06:12:00Z
author_association: MEMBER
diff_hunk: @@ -9,12 +10,21 @@ import {
 } from "@/fawry/types/requests";
 
 function generateSignature(...data: unknown[]) {
path: services/server/src/fawry/lib/signature.ts
user: login: neuodev
body: Using the `unkown` type, give the assumption that it can accept any thing but in reality it only accepts strings or numbers.


```suggestion
function generateSignature(...data: Array<string | number>) {
```
created_at: 2025-04-15T06:13:55Z
author_association: MEMBER
diff_hunk: @@ -9,12 +10,21 @@ import {
 } from "@/fawry/types/requests";
 
 function generateSignature(...data: unknown[]) {
+  // concatenate data values
path: services/server/src/fawry/lib/signature.ts
user: login: neuodev
body: This file is missing the payment using card token: https://developer.fawrystaging.com/docs/card-tokens/create-use-token#create-card-token-head (which we will use)
created_at: 2025-04-15T06:16:51Z
author_association: MEMBER
diff_hunk: @@ -9,12 +10,21 @@ import {
 } from "@/fawry/types/requests";
 
 function generateSignature(...data: unknown[]) {
+  // concatenate data values
   let dataString = "";
   for (const value of data) {
-    dataString += value || "";
+    if (typeof value === "string") dataString += value || "";
+    else if (value?.toString) dataString += value.toString() || "";
   }
-  return btoa(dataString);
+
+  // hash the concatenated string with sha256 digest
+  const sha256 = new jsSHA("SHA-256", "TEXT");
+  sha256.update(dataString);
+
+  // return the hashed value
+  return sha256.getHash("HEX");
 }
+
 /**
  * The SHA-256 digested for the following concatenated string "merchantCode + merchantRefNum +
  * customerProfileId (if exists, otherwise "") + paymentMethod + amount (in two decimal format
path: services/server/src/fawry/lib/signature.ts
user: login: neuodev
body: Let's write the description better. 

```ts
/**
 * SHA-256 of the following
 * 1. merchantCode
 * 2. ...
 */
```
created_at: 2025-04-15T06:18:40Z
author_association: MEMBER
diff_hunk: @@ -5,38 +5,46 @@ import {
 } from "@/fawry/types/ancillaries";
 
 export type GeneralRequestPayload = {
path: services/server/src/fawry/types/requests.ts
user: login: neuodev
body: Let's call it `BaseRequestPayload`. This will indicate that other requests will extend this base type. 
created_at: 2025-04-15T06:24:23Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,351 @@
+import { NextFunction, Request, Response } from "express";
+import safeRequest from "express-async-handler";
+import zod from "zod";
+
+import { isRegularUser } from "@litespace/utils";
+import { IFawry } from "@litespace/types";
+
+import { fawryApi } from "@/fawry/api";
+import { forbidden } from "@/lib/error";
+import { forgeFawryPayload } from "@/lib/fawry";
+import { signature } from "@/fawry/lib";
+import { fawryConfig } from "@/constants";
+import { datetime } from "@/validation/utils";
+import dayjs from "@/lib/dayjs";
+import {
+  DeleteCardTokenPayload,
+  ListCardTokensPayload,
+  RefundPayload,
+} from "@/fawry/types/requests";
+
+const payWithCardPayload = zod.object({
+  amount: zod.number(),
+  cardToken: zod.string(),
+  cvv: zod.number(),
+  returnUrl: zod.string(),
+});
+
+const payWithRefNumPayload = zod.object({
+  amount: zod.number(),
+  paymentExpirey: datetime.optional(),
+});
+
+const payWithEWalletPayload = zod.object({
+  amount: zod.number(),
+  paymentExpirey: datetime.optional(),
+});
+
+const payWithBankInstallmentsPayload = zod.object({
+  planId: zod.number(),
+  amount: zod.number(),
+  cardToken: zod.string(),
+  cvv: zod.number(),
+  returnUrl: zod.string(),
+});
+
+const refundPayload = zod.object({
+  transactionId: zod.number(),
+  refundAmount: zod.number(),
+  reason: zod.string().optional(),
+});
+
+const deleteCardTokenPayload = zod.object({
+  cardToken: zod.string(),
+});
+
+async function payWithCard(req: Request, res: Response, next: NextFunction) {
+  const user = req.user;
+  const allowed = isRegularUser(user);
+  if (!allowed) return next(forbidden());
+
+  const payload = payWithCardPayload.parse(req.body);
+
+  // TODO: store transaction in the database
+
+  const forgedPayload = forgeFawryPayload({
+    merchantRefNum: Math.floor(Math.random() * 1000), // TODO: get transaction id from the db
+    paymentMethod: "CARD",
+    amount: payload.amount,
+    user,
+  });
+  forgedPayload.signature = signature.forGeneralRequest({
+    secureKey: fawryConfig.secureKey,
+    ...forgedPayload,
+  });
+
path: services/server/src/handlers/fawry.ts
user: login: neuodev
body: This is not clean. 
created_at: 2025-04-15T06:27:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,47 @@
+import { GeneralRequestPayload } from "@/fawry/types/requests";
+import { PaymentMethod } from "@/fawry/types/ancillaries";
+import { fawryConfig } from "@/constants";
+import { IUser } from "@litespace/types";
+
+export function forgeFawryPayload<Other = object>({
+  merchantRefNum,
+  amount,
+  paymentMethod,
+  signature,
+  user,
+  chargeItems,
+  description,
+  orderWebHookUrl,
+  other,
+}: {
+  merchantRefNum: number;
+  amount: number;
+  paymentMethod: PaymentMethod;
+  signature?: string;
+  user?: IUser.Self;
+  chargeItems?: GeneralRequestPayload["chargeItems"];
+  description?: string;
+  orderWebHookUrl?: string;
+  other?: Other;
+}) {
+  return {
+    merchantRefNum,
+    paymentMethod,
+    amount,
+    signature: signature || "",
+
+    customerProfileId: user?.id,
+    customerName: user?.name,
+    customerMobile: user?.phone,
+    customerEmail: user?.email,
+
+    chargeItems: chargeItems || [],
path: services/server/src/lib/fawry.ts
user: login: neuodev
body: We don't want to send this as empty array. 
created_at: 2025-04-15T06:28:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,160 @@
+type GeneralResponse = {
+  /**
+   * Fawry Api response status code
+   */
+  statusCode: number;
+  /**
+   * Fawry Api response status description
+   */
+  statusDescription: string;
+};
+
+/**
+ * this's defined by fawry docs (cannot be changed or extended)
+ * https://developer.fawrystaging.com/docs/card-tokens/create-use-token#key_value_list
+ */
+export type OrderStatus =
+  | "NEW"
+  | "PAID"
+  | "CANCELED"
+  | "REFUNDED"
+  | "EXPIRED"
+  | "PARTIAL_REFUNDED"
+  | "FAILED";
+
+export type PayWithCardPayload = {
+  amount: number;
+  cardToken: string;
+  cvv: number;
+  returnUrl: string;
+};
path: packages/types/src/fawry.ts
user: login: neuodev
body: Let's add an empty line between types. 

created_at: 2025-04-15T06:34:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,44 @@
+import { Base } from "@/lib/base";
+import { IFawry } from "@litespace/types";
+
+export class Fawry extends Base {
+  public async payWithCard(
+    payload: IFawry.PayWithCardPayload
+  ): Promise<IFawry.PayWithCardResponse> {
+    return await this.post({ route: "/api/v1/fawry/pay/card-token", payload });
+  }
path: packages/atlas/src/api/fawry.ts
user: login: neuodev
body: Let's add an empty line between methods. 
created_at: 2025-04-15T06:36:01Z
author_association: MEMBER
diff_hunk: @@ -9,12 +10,21 @@ import {
 } from "@/fawry/types/requests";
 
 function generateSignature(...data: unknown[]) {
+  // concatenate data values
   let dataString = "";
   for (const value of data) {
-    dataString += value || "";
+    if (typeof value === "string") dataString += value || "";
+    else if (value?.toString) dataString += value.toString() || "";
   }
-  return btoa(dataString);
+
+  // hash the concatenated string with sha256 digest
+  const sha256 = new jsSHA("SHA-256", "TEXT");
+  sha256.update(dataString);
+
+  // return the hashed value
+  return sha256.getHash("HEX");
 }
+
 /**
  * The SHA-256 digested for the following concatenated string "merchantCode + merchantRefNum +
  * customerProfileId (if exists, otherwise "") + paymentMethod + amount (in two decimal format
path: services/server/src/fawry/lib/signature.ts
user: login: mmoehabb
body: I feel like the syntax written in the docs (of Fawry) is more convenient, explicit, and not misleading. 
`SHA-256 of the following: 1. merchantCode... etc` doesn't illustrate how the string shall be concatenated; the reader (developer) may ask: ok, shall I leave a space between each word or comma?
created_at: 2025-04-15T08:47:29Z
author_association: COLLABORATOR

PR comments:
user: login: neuodev
created_at: 2025-04-14T10:11:32Z
body: 
🚀 [WEB](https://web-gw3oa7byd-litespace-core.vercel.app) 
🚀 [UI](https://ui-k9tscf3bl-litespace-core.vercel.app) 
🚀 [LANDING](https://landing-4ipl5r81a-litespace-core.vercel.app) 
🚀 [DASHBOARD](https://dashboard-a7xtfy0kq-litespace-core.vercel.app) 

author_association: MEMBER
user: login: neuodev
created_at: 2025-04-14T10:17:21Z
body: 
🚀 [WEB](https://web-29o1xv8yz-litespace-core.vercel.app) 
🚀 [UI](https://ui-3urhhc6l2-litespace-core.vercel.app) 
🚀 [LANDING](https://landing-4ipl5r81a-litespace-core.vercel.app) 
🚀 [DASHBOARD](https://dashboard-jrwmfvy3e-litespace-core.vercel.app) 

author_association: MEMBER
user: login: neuodev
created_at: 2025-04-14T10:49:51Z
body: 
🚀 [WEB](https://web-4aqvfw1lo-litespace-core.vercel.app) 
🚀 [UI](https://ui-73698hnnh-litespace-core.vercel.app) 
🚀 [LANDING](https://landing-4ipl5r81a-litespace-core.vercel.app) 
🚀 [DASHBOARD](https://dashboard-7gy7ncty2-litespace-core.vercel.app) 

author_association: MEMBER
user: login: neuodev
created_at: 2025-04-14T11:05:39Z
body: 
🚀 [WEB](https://web-7a3oeihq7-litespace-core.vercel.app) 
🚀 [UI](https://ui-elep54fu2-litespace-core.vercel.app) 
🚀 [LANDING](https://landing-4ipl5r81a-litespace-core.vercel.app) 
🚀 [DASHBOARD](https://dashboard-q81k2bv77-litespace-core.vercel.app) 

author_association: MEMBER
user: login: neuodev
created_at: 2025-04-15T11:29:25Z
body: 
🚀 [WEB](https://web-4wau3dqm6-litespace-core.vercel.app) 
🚀 [UI](https://ui-iar8kotun-litespace-core.vercel.app) 
🚀 [LANDING](https://landing-4ipl5r81a-litespace-core.vercel.app) 
🚀 [DASHBOARD](https://dashboard-eo8b7oiy5-litespace-core.vercel.app) 

author_association: MEMBER
user: login: neuodev
created_at: 2025-04-16T10:43:15Z
body: 
🚀 [WEB](https://web-fcbt8plge-litespace-core.vercel.app) 
🚀 [UI](https://ui-hsnau2vkd-litespace-core.vercel.app) 
🚀 [LANDING](https://landing-4ipl5r81a-litespace-core.vercel.app) 
🚀 [DASHBOARD](https://dashboard-fiss2y2na-litespace-core.vercel.app) 

author_association: MEMBER