diff --git a/.changeset/easy-sides-enjoy.md b/.changeset/easy-sides-enjoy.md new file mode 100644 index 000000000..e63fda54c --- /dev/null +++ b/.changeset/easy-sides-enjoy.md @@ -0,0 +1,5 @@ +--- +'@o2s/integrations.medusajs': minor +--- + +Medusa.js integration implementations for carts, checkout, customers, and payments. diff --git a/.changeset/good-webs-wish.md b/.changeset/good-webs-wish.md new file mode 100644 index 000000000..dafc5fe36 --- /dev/null +++ b/.changeset/good-webs-wish.md @@ -0,0 +1,5 @@ +--- +'@o2s/blocks.product-list': patch +--- + +removed permission check from `ProductList` block as it should be accessible also for unauthenticated users diff --git a/.changeset/lucky-sides-battle.md b/.changeset/lucky-sides-battle.md new file mode 100644 index 000000000..585f5e3d7 --- /dev/null +++ b/.changeset/lucky-sides-battle.md @@ -0,0 +1,9 @@ +--- +'@o2s/framework': minor +--- + +Added the normalized data model for a cart/checkout system with full CRUD operations for items and promotions: + +- Checkout flow supporting address, shipping, and payment setup +- Customer address management for authenticated users +- Payment provider integration and session handling diff --git a/.changeset/red-peaches-strive.md b/.changeset/red-peaches-strive.md new file mode 100644 index 000000000..60cb42d81 --- /dev/null +++ b/.changeset/red-peaches-strive.md @@ -0,0 +1,6 @@ +--- +'@o2s/blocks.product-details': patch +'@o2s/blocks.product-list': patch +--- + +added missing auth token to products service calls diff --git a/.changeset/silent-bees-play.md b/.changeset/silent-bees-play.md new file mode 100644 index 000000000..ffec37bff --- /dev/null +++ b/.changeset/silent-bees-play.md @@ -0,0 +1,5 @@ +--- +'@o2s/integrations.mocked': minor +--- + +Mock integrations updated with carts/customers/payments/checkout mocks and enhanced product/order handling. diff --git a/.gitignore b/.gitignore index 675cd3060..13916a75a 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ dist AGENTS.md CLAUDE.md agent-os + +# Generated files +O2S-API.postman_collection.json diff --git a/apps/api-harmonization/.env.local b/apps/api-harmonization/.env.local index 586cb857c..f6f552b39 100644 --- a/apps/api-harmonization/.env.local +++ b/apps/api-harmonization/.env.local @@ -40,8 +40,6 @@ API_SURVEYJS_BASE_URL=https://api.surveyjs.io/public/v1 MEDUSAJS_BASE_URL= MEDUSAJS_PUBLISHABLE_API_KEY= MEDUSAJS_ADMIN_API_KEY= -MEDUSA_VARIANT_SPEC_FIELDS= -PRODUCTS_BASE_PATH= SEARCH_ARTICLES_INDEX_NAME=mock diff --git a/apps/api-harmonization/src/app.config.ts b/apps/api-harmonization/src/app.config.ts index f95396fea..8da0e5f9d 100644 --- a/apps/api-harmonization/src/app.config.ts +++ b/apps/api-harmonization/src/app.config.ts @@ -4,10 +4,14 @@ import { BillingAccounts, CMS, Cache, + Carts, + Checkout, + Customers, Invoices, Notifications, Orders, Organizations, + Payments, Products, Resources, Search, @@ -32,6 +36,10 @@ export const AppConfig: ApiConfig = { search: Search.SearchIntegrationConfig, products: Products.ProductsIntegrationConfig, orders: Orders.OrdersIntegrationConfig, + carts: Carts.CartsIntegrationConfig, + customers: Customers.CustomersIntegrationConfig, + payments: Payments.PaymentsIntegrationConfig, + checkout: Checkout.CheckoutIntegrationConfig, auth: Auth.AuthIntegrationConfig, }, }; diff --git a/apps/api-harmonization/src/app.module.ts b/apps/api-harmonization/src/app.module.ts index d669d2f09..2c5846418 100644 --- a/apps/api-harmonization/src/app.module.ts +++ b/apps/api-harmonization/src/app.module.ts @@ -13,10 +13,14 @@ import { BillingAccounts, CMS, Cache, + Carts, + Checkout, + Customers, Invoices, Notifications, Orders, Organizations, + Payments, Products, Resources, Search, @@ -79,6 +83,10 @@ export const ArticlesBaseModule = Articles.Module.register(AppConfig); export const SearchBaseModule = Search.Module.register(AppConfig); export const ProductsBaseModule = Products.Module.register(AppConfig); export const OrdersBaseModule = Orders.Module.register(AppConfig); +export const CartsBaseModule = Carts.Module.register(AppConfig); +export const CustomersBaseModule = Customers.Module.register(AppConfig); +export const PaymentsBaseModule = Payments.Module.register(AppConfig); +export const CheckoutBaseModule = Checkout.Module.register(AppConfig); export const AuthModuleBaseModule = AuthModule.Module.register(AppConfig); @Module({ @@ -106,6 +114,10 @@ export const AuthModuleBaseModule = AuthModule.Module.register(AppConfig); SearchBaseModule, ProductsBaseModule, OrdersBaseModule, + CartsBaseModule, + CustomersBaseModule, + PaymentsBaseModule, + CheckoutBaseModule, AuthModuleBaseModule, PageModule.register(AppConfig), diff --git a/apps/docs/blog/releases/o2s/1.5.0.md b/apps/docs/blog/releases/o2s/1.5.0.md index 697389f00..831384ba5 100644 --- a/apps/docs/blog/releases/o2s/1.5.0.md +++ b/apps/docs/blog/releases/o2s/1.5.0.md @@ -61,8 +61,8 @@ The [Zendesk integration](../../../docs/integrations/tickets/zendesk/overview) n ![zendesk integration](../../../static/img/blog/o2s-zendesk-integration.png) - You can now create new tickets via the Tickets API. The Zendesk integration implements `createTicket` and forwards requests to the Zendesk API. - - Attachments are supported when creating tickets. - - Custom field mapping is handled by the `ZendeskFieldMapper`. Configure environment variables (e.g., `ZENDESK_DEVICE_NAME_FIELD_ID`) and add corresponding entries in your CMS mappers to display custom field labels in the UI. + - Attachments are supported when creating tickets. + - Custom field mapping is handled by the `ZendeskFieldMapper`. Configure environment variables (e.g., `ZENDESK_DEVICE_NAME_FIELD_ID`) and add corresponding entries in your CMS mappers to display custom field labels in the UI. - Using our [Survey.js form block](../../../docs/integrations/forms/surveyjs/overview), you can submit custom forms to Zendesk. This gives you full control over form layout (single- or multi-step, splitting fields into sections/columns, and more) independently of Zendesk configuration. ### Dev watch task improvement diff --git a/apps/docs/docs/integrations/articles/zendesk/how-to-setup.md b/apps/docs/docs/integrations/articles/zendesk/how-to-setup.md index aec77d679..b9f6aef01 100644 --- a/apps/docs/docs/integrations/articles/zendesk/how-to-setup.md +++ b/apps/docs/docs/integrations/articles/zendesk/how-to-setup.md @@ -64,10 +64,10 @@ After configuring the integration, you need to set up environment variables that Configure the following environment variables in your API Harmonization server: -| Name | Type | Description | Required | Default | -| ------------------- | ------ | ------------------------------------------------------------------------ | -------- | ------- | -| ZENDESK_API_URL | string | Your Zendesk API URL (e.g., `https://your-subdomain.zendesk.com/api/v2`) | yes | - | -| ZENDESK_API_TOKEN | string | Base64-encoded authentication token | yes | - | +| Name | Type | Description | Required | Default | +| ----------------- | ------ | ------------------------------------------------------------------------ | -------- | ------- | +| ZENDESK_API_URL | string | Your Zendesk API URL (e.g., `https://your-subdomain.zendesk.com/api/v2`) | yes | - | +| ZENDESK_API_TOKEN | string | Base64-encoded authentication token | yes | - | **Important notes:** diff --git a/apps/docs/docs/integrations/articles/zendesk/overview.md b/apps/docs/docs/integrations/articles/zendesk/overview.md index eb484edf0..9dfe7534f 100644 --- a/apps/docs/docs/integrations/articles/zendesk/overview.md +++ b/apps/docs/docs/integrations/articles/zendesk/overview.md @@ -4,7 +4,7 @@ sidebar_position: 100 # Zendesk -This integration provides the integration with [Zendesk Help Center API](https://developer.zendesk.com/api-reference/help_center/help-center-api/introduction/), allowing users to browse knowledge base articles, categories, and search for content within your application. +this package provides the integration with [Zendesk Help Center API](https://developer.zendesk.com/api-reference/help_center/help-center-api/introduction/), allowing users to browse knowledge base articles, categories, and search for content within your application. ## In this section diff --git a/apps/docs/docs/integrations/cache/redis/overview.md b/apps/docs/docs/integrations/cache/redis/overview.md index c186fc54e..217e1eadd 100644 --- a/apps/docs/docs/integrations/cache/redis/overview.md +++ b/apps/docs/docs/integrations/cache/redis/overview.md @@ -4,7 +4,7 @@ sidebar_position: 100 # Redis Cache -This integration provides caching capabilities using [Redis](https://redis.io/). It implements the framework's `Cache.Service` interface and is used by CMS integrations (Strapi, Contentful) to cache pages, components, and configuration. +this package provides caching capabilities using [Redis](https://redis.io/). It implements the framework's `Cache.Service` interface and is used by CMS integrations (Strapi, Contentful) to cache pages, components, and configuration. ## In this section diff --git a/apps/docs/docs/integrations/cms/contentful/overview.md b/apps/docs/docs/integrations/cms/contentful/overview.md index 574ff45de..22aa3b76a 100644 --- a/apps/docs/docs/integrations/cms/contentful/overview.md +++ b/apps/docs/docs/integrations/cms/contentful/overview.md @@ -4,7 +4,7 @@ sidebar_position: 100 # Contentful CMS -This integration provides a full integration with [Contentful CMS](https://www.contentful.com/), enabling comprehensive content management capabilities for your application. The integration supports content management, page management, and content localization, allowing you to create and manage multilingual content with ease. Additionally, Contentful integration includes built-in support for Live Preview, enabling content editors to see their changes in real-time as they edit content in the Contentful web app. +this package provides a full integration with [Contentful CMS](https://www.contentful.com/), enabling comprehensive content management capabilities for your application. The integration supports content management, page management, and content localization, allowing you to create and manage multilingual content with ease. Additionally, Contentful integration includes built-in support for Live Preview, enabling content editors to see their changes in real-time as they edit content in the Contentful web app. ## In this section diff --git a/apps/docs/docs/integrations/cms/strapi/overview.md b/apps/docs/docs/integrations/cms/strapi/overview.md index 0cc338f12..2acfe991b 100644 --- a/apps/docs/docs/integrations/cms/strapi/overview.md +++ b/apps/docs/docs/integrations/cms/strapi/overview.md @@ -4,7 +4,7 @@ sidebar_position: 100 # Strapi CMS -This integration provides a full integration with [Strapi CMS](https://strapi.io/), enabling comprehensive content management capabilities for your application. The integration supports content management, page management, and content localization, allowing you to create and manage multilingual content with ease. +this package provides a full integration with [Strapi CMS](https://strapi.io/), enabling comprehensive content management capabilities for your application. The integration supports content management, page management, and content localization, allowing you to create and manage multilingual content with ease. ## In this section diff --git a/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md b/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md new file mode 100644 index 000000000..72e9bb776 --- /dev/null +++ b/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md @@ -0,0 +1,97 @@ +--- +sidebar_position: 400 +--- + +# Cart & Checkout + +This document describes the cart lifecycle and checkout process when using the Medusa.js integration. It is intended for developers familiar with O2S who need to understand how the Medusa.js flow maps to the O2S cart and checkout model. + +## Understanding MedusaJS Concepts + +If you are familiar with O2S but not Medusa, these concepts are essential: + +### Regions + +Medusa requires a **`region_id`** for every cart. Regions determine: + +- **Pricing** — Currency and tax rules +- **Shipping** — Available shipping options +- **Taxes** — Tax calculation for the region + +O2S maps `regionId` (in request bodies) to Medusa's `region_id`. You must create regions in Medusa Admin and pass a valid `regionId` when creating carts. + +### Variants vs Products + +Medusa uses **product variants** for cart line items, not products directly. Each variant has its own ID, SKU, price, and options (size, color, etc.). + +- **O2S** uses the `sku` field when adding items to cart. +- **Medusa** expects `variant_id` — the integration maps O2S `sku` to Medusa `variant_id`. +- You must use the **variant ID** from the product catalog (e.g., from `GET /products/:id` response), not the product ID. + +### Currency + +Medusa expects lowercase currency codes (e.g., `eur`), while O2S typically uses uppercase (e.g., `EUR`). The integration converts automatically. The `DEFAULT_CURRENCY` environment variable is used when currency is not provided. + +### Cart Ownership + +- **Customer carts** — When a cart is associated with a logged-in customer, the `customerId` is extracted from the SSO token. The integration verifies ownership on cart access; unauthorized access throws `UnauthorizedException`. +- **Guest carts** — Carts created without authentication have no `customerId`. Anyone with the cart ID can access them. + +## O2S to MedusaJS Mapping + +### Cart Model + +The O2S `Cart` model maps to Medusa's `StoreCart`: + +| O2S Field | Medusa Field | Notes | +| ---------------- | ----------------- | -------------------------------------------------- | +| `id` | `id` | Cart identifier | +| `customerId` | `customer_id` | Present when cart is linked to a customer | +| `regionId` | `region_id` | Required for Medusa | +| `currency` | `currency_code` | Normalized to uppercase in O2S | +| `items` | `items` | Line items with variant references | +| `subtotal`, `total`, etc. | Calculated by Medusa | Mapped to O2S `Price` objects | +| `shippingAddress`| `shipping_address`| Set via checkout addresses | +| `billingAddress`| `billing_address` | Set via checkout addresses | +| `shippingMethod` | `shipping_methods[0]` | Single shipping method supported | +| `paymentMethod` | `metadata.paymentMethod` | Stored in cart metadata after setPayment | +| `metadata` | `metadata` | Notes stored in `metadata.notes`; payment session ID in `metadata.paymentSessionId` | + +### Checkout Flow Mapping + +Each O2S checkout step maps to a Medusa operation: + +| O2S Step | Medusa Operation | +| ----------------- | ----------------------------------------------------- | +| `setAddresses` | `sdk.store.cart.update()` with `shipping_address`, `billing_address` | +| `setShippingMethod` | `sdk.store.cart.addShippingMethod()` with `option_id` | +| `setPayment` | `sdk.store.payment.initiatePaymentSession()` then store session ID and payment method in cart metadata | +| `placeOrder` | `sdk.store.cart.complete()` — single call creates the order; no separate order creation endpoint | + +### Payment Session Storage + +After `setPayment`, the integration stores: + +- **`cart.metadata.paymentSessionId`** — ID of the created payment session +- **`cart.metadata.paymentMethod`** — Object with `{ id, name, type }` for display + +These are used by `getCheckoutSummary` and validated before `placeOrder`. + +### Address Handling + +- **Saved addresses** — When `shippingAddressId` or `billingAddressId` is provided (authenticated users), the integration fetches the address from the Customers service and maps it to Medusa format via `mapAddressToMedusa()`. +- **Inline addresses** — When `shippingAddress` or `billingAddress` is provided (guests or one-off addresses), the integration maps directly to Medusa format. +- **Single address** — If only one address is provided, it is used for both shipping and billing. + +## Important Caveats + +| Caveat | Details | +| ------ | ------- | +| **Region required** | `regionId` is required for cart creation. It affects pricing, shipping options, and tax calculation. | +| **Variant ID required** | Use variant ID (from product catalog), not product ID. O2S `sku` expects the variant ID. | +| **Payment session storage** | Payment session ID and payment method are stored in `cart.metadata`. Do not clear metadata when updating cart. | +| **Guest checkout** | Email is required for guest order placement. Provide it in `setAddresses`, `placeOrder`, or `completeCheckout`. | +| **Shipping price calculation** | Options with `price_type=calculated` require a separate API call to `sdk.store.fulfillment.calculate()`. Flat-price options are returned as-is. | +| **Order creation** | Single operation (`cart.complete()`) — no separate order creation endpoint. The order is returned directly from the response. | +| **Cart ownership** | Customer carts verify ownership via SSO token. Unauthorized access throws `UnauthorizedException`. | +| **Unimplemented methods** | `getCartList`, `getCurrentCart`, `deleteCart` are not available due to Store API limitations. | diff --git a/apps/docs/docs/integrations/commerce/medusa-js/features.md b/apps/docs/docs/integrations/commerce/medusa-js/features.md index 13a688b56..1a80523b8 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/features.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/features.md @@ -6,12 +6,16 @@ sidebar_position: 200 This document provides an overview of features supported by the Medusa.js integration. +**Related documentation:** [How to set up](./how-to-setup.md) | [Usage](./usage.md) | [Cart & Checkout](./cart-checkout.md) + ## Overview | Feature | Status | Notes | | --------------------------------------------------- | ------ | -------------------------------------------------------------------------------- | +| [Cart Management](#cart-management) | ✅ | Cart creation, line items, addresses, shipping (Store API) | +| [Checkout Flow](#checkout-flow) | ✅ | Multi-step checkout, payment sessions, order placement | | [Order Management](#order-management) | ✅ | Complete order history and details | -| [Product Catalog](#product-catalog) | ✅ | Product browsing with variants | +| [Product Catalog](#product-catalog) | ✅ | Product browsing with variants (Store API) | | [Product Recommendations](#product-recommendations) | ✅ | Related products ([requires plugin](#plugin-architecture)) | | [Asset Management](#asset-management) | ✅ | Customer assets with warranty tracking ([requires plugin](#plugin-architecture)) | | [Service Subscriptions](#service-subscriptions) | ✅ | Service contracts and billing ([requires plugin](#plugin-architecture)) | @@ -25,6 +29,36 @@ This document provides an overview of features supported by the Medusa.js integr ## Feature Details +### Cart Management {#cart-management} + +The integration provides full cart management via the Medusa Store API (`sdk.store.cart.*`): + +- Create cart with `currency_code`, `region_id`, and optional metadata +- Add line items (requires `variantId` — Medusa uses variants, not productId alone) +- Update and remove cart items +- Update shipping and billing addresses (inline or by saved address ID for authenticated users) +- Add shipping method by `option_id` +- Guest and customer carts supported + +**Limitations (Medusa Store API):** + +- `getCartList` and `getCurrentCart` are not implemented — the Store API does not support listing carts +- `deleteCart` is a no-op (logs only; Store API has no delete endpoint) + +### Checkout Flow {#checkout-flow} + +Complete checkout orchestration from cart to order: + +- **setAddresses** — Set shipping and billing addresses (delegates to Carts) +- **setShippingMethod** — Select shipping option (delegates to Carts) +- **setPayment** — Create payment session via Payments module +- **getShippingOptions** — List available options from Medusa fulfillment API (`fulfillment.listCartOptions` + `fulfillment.calculate` for calculated prices) +- **getCheckoutSummary** — Cart with addresses, shipping, payment, totals +- **placeOrder** — Completes cart via `sdk.store.cart.complete`, returns order +- **completeCheckout** — One-shot flow: addresses → shipping → payment → place order + +Guest checkout supported; email required for guest order placement. See [Cart & Checkout](./cart-checkout.md) for the full flow. + ### Order Management {#order-management} The integration provides comprehensive order management capabilities for customer self-service portals: @@ -41,7 +75,7 @@ Orders are displayed on the **Orders** screen in the frontend app, allowing cust ### Product Catalog {#product-catalog} -Browse and display products from your Medusa commerce platform: +Browse and display products from your Medusa commerce platform using the Store API: - List products with pagination support - Display product details including title, description, and images @@ -50,7 +84,7 @@ Browse and display products from your Medusa commerce platform: - Product type classification (physical vs. virtual products) - Thumbnail and image display -The product catalog powers the **Products** module in the frontend, enabling product listing pages and detailed product views. +The product catalog powers the **Products** module in the frontend, enabling product listing pages and detailed product views. All product operations use Medusa's Store API, which requires SSO token authentication. See [How to set up](./how-to-setup.md#sso-authentication-plugin-setup) for SSO plugin configuration. ### Product Recommendations {#product-recommendations} @@ -123,14 +157,15 @@ Consistent pagination support across all list operations: ### Admin API Integration {#admin-api-integration} -The integration leverages Medusa's Admin API for extended capabilities: +The integration primarily uses Medusa's Store API for customer-facing operations (Products, Orders, Carts, Checkout, Customers, Payments). Store API operations require SSO tokens passed from the frontend and a custom Medusa auth plugin to validate these tokens and map them to customer identities. + +A basic example plugin demonstrating SSO token handling is available at [openselfservice-resources](https://github.com/o2sdev/openselfservice-resources/tree/main/packages/third-party/medusajs/plugins/mocked-auth). The example plugin handles tokens from the mocked integration to find matching customers in Medusa (or create new customers if not found). -- Access to comprehensive order data including financial details -- Extended product information with variants and pricing -- Admin-level operations for resource management -- Secure authentication via API keys +**Admin API is only used for:** +- Related products endpoint (`/admin/products/{id}/variants/{variantId}/references`) - custom endpoint not available in Store API +- Resources plugin endpoints (Assets, Services) - custom endpoints provided by the Assets & Services plugin -This approach provides richer data access compared to the storefront API, enabling full-featured self-service portals. +Admin API operations use `MEDUSAJS_ADMIN_API_KEY` for authentication via API keys. ### Plugin Architecture {#plugin-architecture} diff --git a/apps/docs/docs/integrations/commerce/medusa-js/how-to-setup.md b/apps/docs/docs/integrations/commerce/medusa-js/how-to-setup.md new file mode 100644 index 000000000..ed7874b0a --- /dev/null +++ b/apps/docs/docs/integrations/commerce/medusa-js/how-to-setup.md @@ -0,0 +1,288 @@ +--- +sidebar_position: 150 +--- + +# How to set up + +This guide will walk you through setting up the Medusa.js integration in your Open Self Service application. + +## Install + +Install the Medusa.js integration package in the integrations config workspace: + +```shell +npm install @o2s/integrations.medusajs --workspace=@o2s/configs.integrations +``` + +## Configuration + +After installing the package, configure the integration in the `@o2s/configs.integrations` package. This tells the framework to use Medusa.js instead of the default mocked integration. + +### Step 1: Update the integration configs + +The Medusa.js integration supports multiple modules. Update the corresponding config files for each module you use. + +**Update `packages/configs/integrations/src/models/orders.ts`:** + +```typescript +import { Config, Integration } from '@o2s/integrations.medusajs/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const OrdersIntegrationConfig: ApiConfig['integrations']['orders'] = Config.orders!; + +export import Service = Integration.Orders.Service; +export import Request = Integration.Orders.Request; +export import Model = Integration.Orders.Model; +``` + +**Update `packages/configs/integrations/src/models/products.ts`:** + +```typescript +import { Config, Integration } from '@o2s/integrations.medusajs/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const ProductsIntegrationConfig: ApiConfig['integrations']['products'] = Config.products!; + +export import Service = Integration.Products.Service; +export import Request = Integration.Products.Request; +export import Model = Integration.Products.Model; +``` + +**Update `packages/configs/integrations/src/models/carts.ts`:** + +```typescript +import { Config, Integration } from '@o2s/integrations.medusajs/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const CartsIntegrationConfig: ApiConfig['integrations']['carts'] = Config.carts!; + +export import Service = Integration.Carts.Service; +export import Request = Integration.Carts.Request; +export import Model = Integration.Carts.Model; +``` + +**Update `packages/configs/integrations/src/models/checkout.ts`:** + +```typescript +import { Config, Integration } from '@o2s/integrations.medusajs/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const CheckoutIntegrationConfig: ApiConfig['integrations']['checkout'] = Config.checkout!; + +export import Service = Integration.Checkout.Service; +export import Request = Integration.Checkout.Request; +export import Model = Integration.Checkout.Model; +``` + +**Update `packages/configs/integrations/src/models/customers.ts`** (required for checkout address resolution): + +```typescript +import { Config, Integration } from '@o2s/integrations.medusajs/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const CustomersIntegrationConfig: ApiConfig['integrations']['customers'] = Config.customers!; + +export import Service = Integration.Customers.Service; +export import Request = Integration.Customers.Request; +export import Model = Integration.Customers.Model; +``` + +**Update `packages/configs/integrations/src/models/payments.ts`** (required for checkout): + +```typescript +import { Config, Integration } from '@o2s/integrations.medusajs/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const PaymentsIntegrationConfig: ApiConfig['integrations']['payments'] = Config.payments!; + +export import Service = Integration.Payments.Service; +export import Request = Integration.Payments.Request; +export import Model = Integration.Payments.Model; +``` + +**Update `packages/configs/integrations/src/models/resources.ts`** (if using Resources module): + +```typescript +import { Config, Integration } from '@o2s/integrations.medusajs/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const ResourcesIntegrationConfig: ApiConfig['integrations']['resources'] = Config.resources!; + +export import Service = Integration.Resources.Service; +export import Request = Integration.Resources.Request; +export import Model = Integration.Resources.Model; +``` + +### Step 2: Verify AppConfig + +The `AppConfig` in `apps/api-harmonization/src/app.config.ts` should already reference the integration configs. You don't need to modify this file - it automatically uses the configuration from `@o2s/configs.integrations`. + +## Medusa.js server setup + +You need a running Medusa.js server instance. If you don't have one yet: + +1. **Create a Medusa project** (see [Medusa documentation](https://docs.medusajs.com/learn/get-started)): + + ```shell + npx create-medusa-app@latest my-medusa-store + ``` + +2. **Configure regions and currencies** in Medusa Admin: + - Go to Settings → Regions + - Create at least one region with the currency you want (e.g., EUR, USD) + - Regions affect pricing, shipping options, and tax calculation for carts + +3. **Create API keys** in Medusa Admin: + - **Publishable API key**: Settings → Publishable API Keys → Create. Used for Store API operations. + - **Admin API key**: Settings → API Keys → Create. Used for related products and Resources plugin. + +4. **Add products and variants** so customers can add items to carts. The O2S integration uses variant IDs (not product IDs) when adding items to carts. + +5. **Verify Store API** is accessible at `{MEDUSAJS_BASE_URL}/store/*`. + +## SSO authentication plugin setup + +Medusa's default Store API does not validate SSO JWT tokens. For authenticated operations (cart access, checkout, orders, etc.), you must deploy a custom auth plugin that: + +- Intercepts Store API requests +- Validates JWT tokens from your Auth provider (Auth0, Keycloak, NextAuth, etc.) +- Maps token claims to Medusa customer identities +- Finds existing customers or creates new ones based on token claims + +### Example plugin + +A basic example plugin is available at [openselfservice-resources](https://github.com/o2sdev/openselfservice-resources/tree/main/packages/third-party/medusajs/plugins/mocked-auth). The example demonstrates: + +- Token validation for the mocked integration +- Customer lookup or creation based on token claims +- How to integrate with Medusa's customer model + +### Installation (example plugin) + +If using the example plugin from openselfservice-resources: + +1. Clone or add the plugin package to your Medusa project +2. Register the plugin in your Medusa configuration + + ```ts title="./medusa-config.ts" + module.exports = defineConfig({ + projectConfig: {...}, + modules: [ + { + resolve: "@medusajs/medusa/auth", + options: { + providers: [ + // Default email/password provider for admin login + { + resolve: "@medusajs/auth-emailpass", + id: "emailpass", + }, + // Third-party JWT provider for customer authentication + { + resolve: "./src/plugins/mocked-auth/providers", + id: "third-party-jwt", + options: { + jwtSecret: process.env.THIRD_PARTY_AUTH_JWT_SECRET, + }, + }, + ], + }, + }, + ], + }) + ``` + +3. Configure the plugin to validate tokens from your auth provider + + ```ts title="./src/api/middlewares.ts" + export default defineMiddlewares({ + routes: [ + { + matcher: '/store/*', + middlewares: [ + createThirdPartyAuthMiddleware({ + ...thirdPartyAuthOptions, + // Public routes should still work without a token + allowUnauthenticated: true, + }), + ], + }, + ], + }); + ``` + +### Customization for production + +For production, you will typically: + +- Replace token validation logic with your Auth0/Keycloak/NextAuth validation +- Adjust customer mapping (email, ID, etc.) to match your token claims +- Handle error responses when tokens are invalid or expired +- Ensure customer creation logic follows your business rules + +## Environment variables + +Configure the following environment variables in your API Harmonization server: + +| Name | Type | Description | Required | +| ---------------------------- | ------ | -------------------------------------------------------------------- | -------- | +| MEDUSAJS_BASE_URL | string | The base URL of your Medusa instance (e.g., `http://localhost:9000`) | yes | +| MEDUSAJS_PUBLISHABLE_API_KEY | string | The publishable API key for Store API operations | yes | +| MEDUSAJS_ADMIN_API_KEY | string | The admin API key for Admin API operations | yes | +| DEFAULT_CURRENCY | string | The default currency code (e.g., `EUR`, `USD`, `PLN`) | yes | + +You can obtain these values from your Medusa Admin Panel: + +1. **Base URL**: The URL where your Medusa server is running +2. **Publishable API Key**: Create in Medusa Admin under Settings → Publishable API Keys +3. **Admin API Key**: Create in Medusa Admin under Settings → API Keys + +### Example `.env` configuration + +```env +MEDUSAJS_BASE_URL=http://localhost:9000 +MEDUSAJS_PUBLISHABLE_API_KEY=pk_xxxxxxxx +MEDUSAJS_ADMIN_API_KEY=sk_xxxxxxxx +DEFAULT_CURRENCY=EUR +``` + +## Verify installation + +After completing the setup: + +1. **Rebuild the configs package** (if needed): + + ```shell + npm run build --workspace=@o2s/configs.integrations + ``` + +2. **Start the API Harmonization server** and the dev stack: + + ```shell + npm run dev + ``` + +3. **Verify** by: + - Checking server logs for successful module registration + - Testing product listing: `GET /products` + - Creating a cart: `POST /carts` with `{ "currency": "EUR", "regionId": "" }` + - Ensuring SSO plugin is deployed if you need authenticated operations + +## Troubleshooting + +| Problem | Solution | +| ---------------------------------------------- | ---------------------------------------------------------------------------------- | +| Module not found | Verify the package is installed: `npm list @o2s/integrations.medusajs` | +| Cannot connect to Medusa | Check `MEDUSAJS_BASE_URL` is correct and Medusa server is running | +| 401/403 on Store API | Ensure SSO auth plugin is deployed and tokens are passed in `Authorization` header | +| Missing region when creating cart | Provide `regionId` in cart creation; create regions in Medusa Admin | +| Payment providers not found | Configure payment providers in Medusa; ensure region has payment providers | +| `getCartList` / `getCurrentCart` not supported | These are Store API limitations; use `getCart` with known cart ID | +```` diff --git a/apps/docs/docs/integrations/commerce/medusa-js/overview.md b/apps/docs/docs/integrations/commerce/medusa-js/overview.md index 6b0a8d684..09f3c85d2 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/overview.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/overview.md @@ -4,116 +4,73 @@ sidebar_position: 100 # Medusa.js -This integration provides a full integration with [Medusa.js](https://medusajs.com/) - the open-source commerce platform for digital commerce. The integration enables order management, product catalog browsing, and resource management (assets and services) for customer self-service scenarios. +this package provides a full integration with [Medusa.js](https://medusajs.com/) - the open-source commerce platform for digital commerce. The integration enables order management, product catalog browsing, cart and checkout, and resource management (assets and services) for customer self-service scenarios. ## In this section -- [Features](./features.md) - Overview of features supported by the Medusa.js integration +- [How to set up](./how-to-setup.md) - Step-by-step guide for setting up the Medusa.js integration +- [Features](./features.md) - Overview of features and capabilities provided by the integration +- [Usage](./usage.md) - Examples and API reference for all modules +- [Cart & Checkout](./cart-checkout.md) - Cart lifecycle and checkout process -## Installation +## What is Medusa.js? -First, install the Medusa.js integration package: +Medusa.js is an open-source headless commerce platform that provides: -```shell -npm install @o2s/integrations.medusajs --workspace=@o2s/configs.integrations -``` - -## Configuration - -After installing the package, you need to configure the integration in the `@o2s/configs.integrations` package. This tells the framework to use Medusa.js instead of the default mocked integration. - -### Step 1: Update the integration configs - -The Medusa.js integration supports multiple modules. You need to update the corresponding config files: - -**Update `packages/configs/integrations/src/models/orders.ts`:** - -```typescript -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; - -import { ApiConfig } from '@o2s/framework/modules'; - -export const OrdersIntegrationConfig: ApiConfig['integrations']['orders'] = Config.orders!; - -export import Service = Integration.Orders.Service; -export import Request = Integration.Orders.Request; -export import Model = Integration.Orders.Model; -``` - -**Update `packages/configs/integrations/src/models/products.ts`:** - -```typescript -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; - -import { ApiConfig } from '@o2s/framework/modules'; +- Product catalog with variants and pricing +- Cart and checkout flow +- Order management +- Customer and address management +- Payment provider integrations +- Regional configuration (currencies, shipping, taxes) +- Extensible plugin architecture -export const ProductsIntegrationConfig: ApiConfig['integrations']['products'] = Config.products!; +This integration connects your Open Self Service application with Medusa, enabling customers to browse products, manage carts, complete checkout, and view orders directly within your application. -export import Service = Integration.Products.Service; -export import Request = Integration.Products.Request; -export import Model = Integration.Products.Model; -``` - -**Update `packages/configs/integrations/src/models/resources.ts` (if using Resources module):** - -```typescript -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; - -import { ApiConfig } from '@o2s/framework/modules'; - -export const ResourcesIntegrationConfig: ApiConfig['integrations']['resources'] = Config.resources!; - -export import Service = Integration.Resources.Service; -export import Request = Integration.Resources.Request; -export import Model = Integration.Resources.Model; -``` - -### Step 2: Verify AppConfig - -The `AppConfig` in `apps/api-harmonization/src/app.config.ts` should already reference the integration configs. You don't need to modify this file - it automatically uses the configuration from `@o2s/configs.integrations`. +## Supported modules -## Environment variables +The integration implements these modules from the O2S framework: -Configure the following environment variables in your API Harmonization server: +| Module | Description | Plugin Required | +| -------- | ----------------------------------------------- | --------------- | +| Carts | Cart creation, line items, addresses, shipping | No | +| Checkout | Multi-step checkout and order placement | No | +| Customers| Address management for authenticated users | No | +| Orders | Order management and history | No | +| Payments | Payment session creation and validation | No | +| Products | Product catalog and variants | No | +| Resources| Assets and service instances management | Yes | -| Name | Type | Description | Required | -| ---------------------------- | ------ | ---------------------------------------------------------------- | -------- | -| MEDUSAJS_BASE_URL | string | The base URL pointing to the domain hosting your Medusa instance | yes | -| MEDUSAJS_PUBLISHABLE_API_KEY | string | The publishable API key for storefront operations | yes | -| MEDUSAJS_ADMIN_API_KEY | string | The admin user's API key for administrative operations | yes | -| DEFAULT_CURRENCY | string | The default currency code (e.g., `EUR`, `USD`, `PLN`) | yes | +## Quick start -You can obtain these values from your Medusa Admin Panel: +1. Install the package (see [How to set up](./how-to-setup.md)) +2. Configure the integration in `@o2s/configs.integrations` (Carts, Checkout, Customers, Orders, Payments, Products) +3. Set up a Medusa.js server instance +4. Deploy the SSO authentication plugin (required for Store API operations) +5. Configure environment variables -1. **Base URL**: The URL where your Medusa server is running (e.g., `http://localhost:9000` for local development) -2. **Publishable API Key**: Create in Medusa Admin under "Settings" → "Publishable API Keys" -3. **Admin API Key**: Create in Medusa Admin under "Settings" → "API Keys" +For detailed instructions, see the [How to set up](./how-to-setup.md) guide. -For more details about authentication setup, see the official [Medusa.js SDK documentation](https://docs.medusajs.com/resources/js-sdk). +## Requirements -## Supported modules +- A Medusa.js server instance (running Store API and Admin API) +- SSO authentication plugin (custom) - Medusa's default Store API does not validate SSO tokens; you must deploy a custom plugin to validate JWT tokens and map them to customer identities +- Publishable API key from Medusa Admin (for Store API operations) +- Admin API key from Medusa Admin (for related products and Resources plugin) +- `DEFAULT_CURRENCY` environment variable -The integration implements three core modules from the O2S framework: +:::info SSO Authentication Plugin Required -| Module | Description | Plugin Required | -| --------- | --------------------------------------- | --------------- | -| Orders | Order management and history | No | -| Products | Product catalog and variants | No | -| Resources | Assets and service instances management | Yes | +Store API operations (Products, Orders, Carts, Checkout, Customers, Payments) require SSO token authentication. A basic example plugin is available at [openselfservice-resources](https://github.com/o2sdev/openselfservice-resources/tree/main/packages/third-party/medusajs/plugins/mocked-auth) that demonstrates how to validate SSO tokens and map them to Medusa customer identities. -## Dependencies - -This integration relies on: +::: -- **[@medusajs/js-sdk](https://www.npmjs.com/package/@medusajs/js-sdk)** - Official Medusa.js SDK for API communication -- **[medusa-plugin-assets-services](https://github.com/o2sdev/medusa-plugin-assets-services)** - Custom O2S plugin for Assets and Services management (required for Resources module) +## Architecture -:::info Custom Plugin Required -To use the **Resources** module (Assets & Services), you must install our custom Medusa plugin in your Medusa instance. The plugin adds endpoints for managing customer assets, service instances, and product relations. +The integration uses the official Medusa.js SDK for most operations: -See the plugin repository for installation instructions: [medusa-plugin-assets-services](https://github.com/o2sdev/medusa-plugin-assets-services) -::: -The integration uses the official Medusa.js SDK for most operations, combined with direct HTTP calls for custom endpoints provided by the Assets & Services plugin. All API calls are authenticated using a combination of publishable API key and admin API key. +- **Store API** (Products, Orders, Carts, Checkout, Customers, Payments): Uses publishable API key + SSO JWT tokens. Requires a custom Medusa auth plugin. +- **Admin API** (Related Products, Resources plugin): Uses admin API key for authentication. ```text ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ @@ -121,10 +78,14 @@ The integration uses the official Medusa.js SDK for most operations, combined wi └─────────────────┘ └──────────────────┘ └─────────────────┘ │ │ │ Medusa.js SDK │ Admin API - │ HTTP Client │ Store API + │ Store API │ (Related Products, + │ (Products, Orders, │ Resources plugin) + │ Carts, Checkout, │ + │ Customers, Payments) │ + │ Requires SSO auth plugin│ ▼ ▼ ┌──────────────────┐ ┌─────────────────┐ - │ Orders/Products │ │ Assets/Services │ - │ (native) │ │ (plugin) │ + │ Carts/Checkout/ │ │ Assets/Services │ + │ Orders/Products │ │ (plugin) │ └──────────────────┘ └─────────────────┘ ``` diff --git a/apps/docs/docs/integrations/commerce/medusa-js/usage.md b/apps/docs/docs/integrations/commerce/medusa-js/usage.md new file mode 100644 index 000000000..34793fb05 --- /dev/null +++ b/apps/docs/docs/integrations/commerce/medusa-js/usage.md @@ -0,0 +1,320 @@ +--- +sidebar_position: 300 +--- + +# Usage + +This page provides examples and API reference for using the Medusa.js integration. + +## API Endpoints Overview + +The Medusa.js integration exposes endpoints for Products, Orders, Carts, Checkout, Customers, and Payments. All endpoints follow the O2S framework API specification. For the full checkout flow, see [Cart & Checkout](./cart-checkout.md). + +## Products API + +### List Products + +**Endpoint:** `GET /products` + +**Query Parameters:** + +| Parameter | Type | Description | Required | +| --------- | ------ | ---------------------------------------- | -------- | +| limit | number | Number of products per page | No | +| offset | number | Pagination offset | No | +| type | string | Filter by product type | No | +| category | string | Filter by category | No | +| sort | string | Sort order | No | + +**Example:** + +```bash +GET /products?limit=20&offset=0 +Authorization: Bearer {token} +``` + +### Get Product + +**Endpoint:** `GET /products/:id` + +**Path Parameters:** + +| Parameter | Type | Description | Required | +| --------- | ------ | -------------- | -------- | +| id | string | The product ID | Yes | + +**Example:** + +```bash +GET /products/prod_01ABC123 +Authorization: Bearer {token} +``` + +### Get Related Products + +**Endpoint:** `GET /products/:id/variants/:variantId/related-products` + +Uses the Medusa Admin API (requires admin key). Returns products related to the given product variant. + +**Path Parameters:** + +| Parameter | Type | Description | Required | +| --------- | ------ | --------------- | -------- | +| id | string | The product ID | Yes | +| variantId | string | The variant ID | Yes | + +**Query Parameters:** + +| Parameter | Type | Description | Required | +| --------- | ------ | ---------------- | -------- | +| type | string | Reference type | No | +| limit | number | Max results | No | +| offset | number | Pagination offset| No | +| sort | string | Sort order | No | + +**Example:** + +```bash +GET /products/prod_01ABC123/variants/var_01XYZ789/related-products +Authorization: Bearer {token} +``` + +## Orders API + +### List Orders + +**Endpoint:** `GET /orders` + +**Query Parameters:** + +| Parameter | Type | Description | Required | +| ------------ | ------ | ------------------------------ | -------- | +| limit | number | Number of orders per page | No | +| offset | number | Pagination offset | No | +| status | string | Filter by order status | No | +| paymentStatus| string | Filter by payment status | No | +| dateFrom | string | Orders from this date (ISO) | No | +| dateTo | string | Orders until this date (ISO) | No | +| sort | string | Sort order | No | + +**Example:** + +```bash +GET /orders?limit=20&status=completed +Authorization: Bearer {token} +``` + +### Get Order + +**Endpoint:** `GET /orders/:id` + +**Path Parameters:** + +| Parameter | Type | Description | Required | +| --------- | ------ | -------------- | -------- | +| id | string | The order ID | Yes | + +**Example:** + +```bash +GET /orders/order_01ABC123 +Authorization: Bearer {token} +``` + +## Carts API + +### Create Cart + +**Endpoint:** `POST /carts` + +**Body:** + +| Field | Type | Description | Required | +| --------- | ------ | ---------------------------------- | -------- | +| currency | string | Currency code (e.g., EUR, USD) | Yes | +| regionId | string | Medusa region ID (required) | Yes | +| metadata | object | Optional metadata | No | + +**Example:** + +```bash +POST /carts +Content-Type: application/json + +{ + "currency": "EUR", + "regionId": "reg_01ABC123", + "metadata": {} +} +``` + +### Get Cart + +**Endpoint:** `GET /carts/:id` + +**Path Parameters:** + +| Parameter | Type | Description | Required | +| --------- | ------ | -------------- | -------- | +| id | string | The cart ID | Yes | + +**Example:** + +```bash +GET /carts/cart_01ABC123 +Authorization: Bearer {token} +``` + +### Update Cart + +**Endpoint:** `PATCH /carts/:id` + +**Body:** + +| Field | Type | Description | Required | +| ----------------- | ------ | ---------------------------------- | -------- | +| regionId | string | Medusa region ID | No | +| email | string | Email (for guest checkout) | No | +| notes | string | Order notes | No | +| metadata | object | Optional metadata | No | + +### Add Cart Item + +**Endpoint:** `POST /carts/items` + +**Body:** + +| Field | Type | Description | Required | +| ------- | ------ | ---------------------------------------------------- | -------- | +| cartId | string | Existing cart ID (omit to create new cart) | No | +| sku | string | Variant ID from product catalog (Medusa variant_id) | Yes | +| quantity| number | Quantity | Yes | +| currency| string | Required when creating new cart | No | +| regionId| string | Required when creating new cart | No | +| metadata| object | Optional metadata | No | + +**Example:** + +```bash +POST /carts/items +Content-Type: application/json + +{ + "cartId": "cart_01ABC123", + "sku": "var_01XYZ789", + "quantity": 2 +} +``` + +### Update Cart Item + +**Endpoint:** `PATCH /carts/:cartId/items/:itemId` + +### Remove Cart Item + +**Endpoint:** `DELETE /carts/:cartId/items/:itemId` + +### Apply Promotion + +**Endpoint:** `POST /carts/:cartId/promotions` + +**Body:** + +| Field | Type | Description | Required | +| ----- | ------ | ---------------- | -------- | +| code | string | Promotion code | Yes | + +### Remove Promotion + +**Endpoint:** `DELETE /carts/:cartId/promotions/:code` + +## Checkout API + +For the full checkout flow, see [Cart & Checkout](./cart-checkout.md). + +| Method | Endpoint | Purpose | +| ------ | ------------------------------------ | ------------------------------------ | +| POST | `/checkout/:cartId/addresses` | Set shipping and billing addresses | +| POST | `/checkout/:cartId/shipping-method` | Select shipping option | +| POST | `/checkout/:cartId/payment` | Create payment session | +| GET | `/checkout/:cartId/shipping-options` | List available shipping options | +| GET | `/checkout/:cartId/summary` | Get checkout summary | +| POST | `/checkout/:cartId/place-order` | Create order from cart | +| POST | `/checkout/:cartId/complete` | One-shot complete flow | + +## Customers API + +Customer address management. All endpoints require authentication. + +**Base path:** `/customers/addresses` + +| Method | Endpoint | Purpose | +| ------ | --------------------------- | -------------------- | +| GET | `/customers/addresses` | List addresses | +| GET | `/customers/addresses/:id` | Get address | +| POST | `/customers/addresses` | Create address | +| PATCH | `/customers/addresses/:id` | Update address | +| DELETE | `/customers/addresses/:id` | Delete address | +| POST | `/customers/addresses/:id/default` | Set default address | + +## Payments API + +### List Payment Providers + +**Endpoint:** `GET /payments/providers` + +**Query Parameters:** + +| Parameter | Type | Description | Required | +| --------- | ------ | ------------------------------ | -------- | +| regionId | string | Medusa region ID | Yes | + +**Example:** + +```bash +GET /payments/providers?regionId=reg_01ABC123 +Authorization: Bearer {token} +``` + +### Create Payment Session + +**Endpoint:** `POST /payments/sessions` + +**Body:** + +| Field | Type | Description | Required | +| ----------- | ------ | ----------------------------------- | -------- | +| cartId | string | Cart ID | Yes | +| providerId | string | Payment provider ID from Medusa | Yes | +| returnUrl | string | Redirect URL after payment | Yes | +| cancelUrl | string | Redirect URL if payment cancelled | No | +| metadata | object | Optional metadata | No | + +**Example:** + +```bash +POST /payments/sessions +Content-Type: application/json +Authorization: Bearer {token} + +{ + "cartId": "cart_01ABC123", + "providerId": "pp_stripe", + "returnUrl": "https://example.com/checkout/success", + "cancelUrl": "https://example.com/checkout/cancel" +} +``` + +**Note:** The Medusa integration does not implement `getSession`, `updateSession`, or `cancelSession` due to SDK limitations. Use `createSession` and proceed to place order. + +## Authentication + +Store API operations (Products, Orders, Carts, Checkout, Customers, Payments) require SSO token authentication. Pass the JWT token in the `Authorization` header: + +``` +Authorization: Bearer {your_sso_token} +``` + +The integration forwards this token to Medusa. A custom SSO auth plugin must be deployed on your Medusa server to validate the token and map it to a Medusa customer. See [How to set up](./how-to-setup.md#sso-authentication-plugin-setup). + +**Guest checkout:** Cart creation and adding items can work without authentication. For checkout completion, provide an email (in addresses or place-order body). diff --git a/apps/docs/docs/integrations/forms/surveyjs/overview.md b/apps/docs/docs/integrations/forms/surveyjs/overview.md index 7e922e7a6..d703c9197 100644 --- a/apps/docs/docs/integrations/forms/surveyjs/overview.md +++ b/apps/docs/docs/integrations/forms/surveyjs/overview.md @@ -4,7 +4,7 @@ sidebar_position: 100 # SurveyJS -This integration provides a full integration with [SurveyJS](https://surveyjs.io/), a powerful JavaScript library for creating dynamic forms and surveys. Unlike other integrations located in `packages/integrations`, SurveyJS is implemented as a separate module that can be added to the API Harmonization server. +this package provides a full integration with [SurveyJS](https://surveyjs.io/), a powerful JavaScript library for creating dynamic forms and surveys. Unlike other integrations located in `packages/integrations`, SurveyJS is implemented as a separate module that can be added to the API Harmonization server. ## In this section diff --git a/apps/docs/docs/integrations/search/algolia/overview.md b/apps/docs/docs/integrations/search/algolia/overview.md index a692eb529..b93348ff2 100644 --- a/apps/docs/docs/integrations/search/algolia/overview.md +++ b/apps/docs/docs/integrations/search/algolia/overview.md @@ -4,7 +4,7 @@ sidebar_position: 100 # Algolia -This integration provides a full integration with [Algolia](https://www.algolia.com/), the AI-powered search and discovery platform. It enables powerful search capabilities for your application, particularly for knowledge base articles. +this package provides a full integration with [Algolia](https://www.algolia.com/), the AI-powered search and discovery platform. It enables powerful search capabilities for your application, particularly for knowledge base articles. ## In this section diff --git a/apps/docs/docs/integrations/tickets/zendesk/features.md b/apps/docs/docs/integrations/tickets/zendesk/features.md index da91ec5fa..93b909447 100644 --- a/apps/docs/docs/integrations/tickets/zendesk/features.md +++ b/apps/docs/docs/integrations/tickets/zendesk/features.md @@ -102,17 +102,17 @@ The integration maps Zendesk ticket data to the standard ticket model with the f ### Field Mapping -| Zendesk Field | Normalized Field | Notes | -| -------------------- |------------------|------------------------------------------| -| id | id | Converted to string | -| created_at | createdAt | ISO date string | -| updated_at | updatedAt | ISO date string | -| status | status | Mapped according to status mapping | -| subject | properties | Added as property with id 'subject' | -| description | properties | Added as property with id 'description' | +| Zendesk Field | Normalized Field | Notes | +| -------------------- | ---------------- | ------------------------------------------------------------------------------------- | +| id | id | Converted to string | +| created_at | createdAt | ISO date string | +| updated_at | updatedAt | ISO date string | +| status | status | Mapped according to status mapping | +| subject | properties | Added as property with id 'subject' | +| description | properties | Added as property with id 'description' | | custom_fields | properties | Mapped using `ZendeskFieldMapper` to readable names (see Custom Fields section below) | -| comments | comments | Mapped with author information | -| comments.attachments | attachments | Extracted from comments | +| comments | comments | Mapped with author information | +| comments.attachments | attachments | Extracted from comments | ### Status Mapping @@ -123,7 +123,6 @@ The integration maps Zendesk ticket data to the standard ticket model with the f | new, open | OPEN | Default status | | (other) | OPEN | Fallback for unknown statuses | - ### Custom Fields Mapping Custom fields from Zendesk are mapped to readable names using the `ZendeskFieldMapper`. This provides a consistent, maintainable way to work with custom fields throughout the application. @@ -131,57 +130,58 @@ Custom fields from Zendesk are mapped to readable names using the `ZendeskFieldM **How it works:** 1. **Field Mapping Configuration**: Custom fields are defined in `ZendeskFieldMapper` with readable names and environment variable IDs: - ```typescript - // In zendesk-field.mapper.ts - fieldMap = { - machineName: process.env.ZENDESK_DEVICE_NAME_FIELD_ID, - serialNumber: process.env.ZENDESK_SERIAL_NUMBER_FIELD_ID, - maintenanceType: process.env.ZENDESK_MAINTENANCE_TYPE_FIELD_ID, - // ... more fields - } - ``` + + ```typescript + // In zendesk-field.mapper.ts + fieldMap = { + machineName: process.env.ZENDESK_DEVICE_NAME_FIELD_ID, + serialNumber: process.env.ZENDESK_SERIAL_NUMBER_FIELD_ID, + maintenanceType: process.env.ZENDESK_MAINTENANCE_TYPE_FIELD_ID, + // ... more fields + }; + ``` 2. **Reading Tickets**: When a ticket is retrieved from Zendesk, custom fields are automatically mapped to their readable names: - - Custom field with ID `123456` → `machineName` (if configured in `ZendeskFieldMapper`) - - Only fields with mappings in `ZendeskFieldMapper` are included - - Fields without mappings are skipped + - Custom field with ID `123456` → `machineName` (if configured in `ZendeskFieldMapper`) + - Only fields with mappings in `ZendeskFieldMapper` are included + - Fields without mappings are skipped 3. **CMS Integration**: To display custom fields in ticket details, add mappings in CMS: - ```typescript - // In CMS mapper (e.g., mocked, contentful, strapi) - properties: { - // ... standard fields - machineName: 'Machine Name', - serialNumber: 'Serial Number', - } - ``` + ```typescript + // In CMS mapper (e.g., mocked, contentful, strapi) + properties: { + // ... standard fields + machineName: 'Machine Name', + serialNumber: 'Serial Number', + } + ``` **Adding a new custom field:** To add support for a new custom field: 1. **Add environment variable**: - ```env - ZENDESK_NEW_FIELD_ID=789012 - ``` + + ```env + ZENDESK_NEW_FIELD_ID=789012 + ``` 2. **Add to ZendeskFieldMapper** in `zendesk-field.mapper.ts`: - ```typescript - fieldMap = { - // ... existing fields - newField: process.env.ZENDESK_NEW_FIELD_ID - ? Number(process.env.ZENDESK_NEW_FIELD_ID) - : undefined, - } - ``` + + ```typescript + fieldMap = { + // ... existing fields + newField: process.env.ZENDESK_NEW_FIELD_ID ? Number(process.env.ZENDESK_NEW_FIELD_ID) : undefined, + }; + ``` 3. **Add CMS mappings** for all supported locales (in mocked, contentful, strapi mappers): - ```typescript - properties: { - // ... existing fields - newField: 'New Field Label', // Add for each locale - } - ``` + ```typescript + properties: { + // ... existing fields + newField: 'New Field Label', // Add for each locale + } + ``` ### Topic Field Mapping @@ -192,9 +192,9 @@ The `topic` field is automatically set during ticket creation based on the `type When creating a ticket via the Zendesk integration: 1. The system compares the `type` (ticket form ID) with configured environment variables: - - `ZENDESK_CONTACT_FORM_ID` → topic value: `CONTACT_US` - - `ZENDESK_COMPLAINT_FORM_ID` → topic value: `COMPLAINT` - - `ZENDESK_REQUEST_DEVICE_MAINTENANCE_FORM_ID` → topic value: `REQUEST_DEVICE_MAINTENANCE` + - `ZENDESK_CONTACT_FORM_ID` → topic value: `CONTACT_US` + - `ZENDESK_COMPLAINT_FORM_ID` → topic value: `COMPLAINT` + - `ZENDESK_REQUEST_DEVICE_MAINTENANCE_FORM_ID` → topic value: `REQUEST_DEVICE_MAINTENANCE` 2. The matching topic value is automatically added to the ticket's fields @@ -222,6 +222,7 @@ When creating a ticket via the Zendesk integration: **Important**: If the `type` doesn't match any configured form ID, the ticket creation will fail with a `BadRequestException`. This ensures that all tickets are properly categorized. **Supported topic values:** + - `CONTACT_US` - General contact inquiries - `COMPLAINT` - Customer complaints - `REQUEST_DEVICE_MAINTENANCE` - Device maintenance requests @@ -261,14 +262,14 @@ The integration converts framework filter parameters to Zendesk Search API queri ### Parameter Mapping -| Framework Parameter | Zendesk Search Query | Notes | -|---------------------|----------------------|------------------------------------------| -| status | `status:{value}` | Converted to lowercase | -| topic | `tag:{value}` | Maps to Zendesk tags | -| dateFrom | `created>={iso_date}` | Converted to ISO format | -| dateTo | `created<={iso_date}` | Converted to ISO format | -| offset | `page` | Calculated as `Math.floor(offset / limit) + 1` | -| limit | `per_page` | Default: 10 | +| Framework Parameter | Zendesk Search Query | Notes | +| ------------------- | --------------------- | ---------------------------------------------- | +| status | `status:{value}` | Converted to lowercase | +| topic | `tag:{value}` | Maps to Zendesk tags | +| dateFrom | `created>={iso_date}` | Converted to ISO format | +| dateTo | `created<={iso_date}` | Converted to ISO format | +| offset | `page` | Calculated as `Math.floor(offset / limit) + 1` | +| limit | `per_page` | Default: 10 | ### Base Query diff --git a/apps/docs/docs/integrations/tickets/zendesk/how-to-setup.md b/apps/docs/docs/integrations/tickets/zendesk/how-to-setup.md index b50b2087d..8de828248 100644 --- a/apps/docs/docs/integrations/tickets/zendesk/how-to-setup.md +++ b/apps/docs/docs/integrations/tickets/zendesk/how-to-setup.md @@ -69,9 +69,9 @@ Configure the following environment variables in your API Harmonization server: | ZENDESK_API_URL | string | Your Zendesk API URL (e.g., `https://your-subdomain.zendesk.com/api/v2`) | yes | - | | ZENDESK_API_TOKEN | string | Base64-encoded authentication token | yes | - | | ZENDESK_TOPIC_FIELD_ID | number | Custom field ID for ticket topic | yes | - | -| ZENDESK_CONTACT_FORM_ID | number | Ticket form ID for contact inquiries | yes | - | -| ZENDESK_COMPLAINT_FORM_ID | number | Ticket form ID for complaints | yes | - | -| ZENDESK_REQUEST_DEVICE_MAINTENANCE_FORM_ID | number | Ticket form ID for device maintenance requests | yes | - | +| ZENDESK_CONTACT_FORM_ID | number | Ticket form ID for contact inquiries | yes | - | +| ZENDESK_COMPLAINT_FORM_ID | number | Ticket form ID for complaints | yes | - | +| ZENDESK_REQUEST_DEVICE_MAINTENANCE_FORM_ID | number | Ticket form ID for device maintenance requests | yes | - | | ZENDESK_DEVICE_NAME_FIELD_ID | number | Custom field ID for device/machine name | yes | - | | ZENDESK_SERIAL_NUMBER_FIELD_ID | number | Custom field ID for serial number | yes | - | | ZENDESK_MAINTENANCE_TYPE_FIELD_ID | number | Custom field ID for maintenance type | yes | - | diff --git a/apps/docs/docs/integrations/tickets/zendesk/overview.md b/apps/docs/docs/integrations/tickets/zendesk/overview.md index 435d39155..b99452e6d 100644 --- a/apps/docs/docs/integrations/tickets/zendesk/overview.md +++ b/apps/docs/docs/integrations/tickets/zendesk/overview.md @@ -4,7 +4,7 @@ sidebar_position: 100 # Zendesk -This integration provides the integration with [Zendesk Ticketing module](https://developer.zendesk.com/api-reference/ticketing/introduction/), allowing users to view and manage their support tickets within your application. +this package provides the integration with [Zendesk Ticketing module](https://developer.zendesk.com/api-reference/ticketing/introduction/), allowing users to view and manage their support tickets within your application. ## In this section diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-carts.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-carts.md new file mode 100644 index 000000000..b3bd8ce6c --- /dev/null +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-carts.md @@ -0,0 +1,304 @@ +--- +sidebar_position: 350 +--- + +# Carts + +The carts model represents shopping cart data, including line items, addresses, shipping methods, promotions, and payment methods. It enables cart management for e-commerce checkout flows, supporting both guest and authenticated users. + +## Cart Service + +The `CartService` provides methods to interact with cart data. + +### getCart + +Retrieves a specific cart by ID. + +```typescript +getCart( + params: GetCartParams, + authorization?: string +): Observable +``` + +#### Parameters + +| Parameter | Type | Description | +| ------------- | ------------- | --------------------------------- | +| params | GetCartParams | Parameters containing the cart ID | +| authorization | string | Optional authorization header | + +#### Returns + +An Observable that emits the requested cart or undefined if not found. + +### getCartList + +Retrieves a paginated list of carts with optional filtering (authenticated users). + +```typescript +getCartList( + query: GetCartListQuery, + authorization?: string +): Observable +``` + +#### Query Parameters + +| Parameter | Type | Description | +| ---------- | -------- | --------------------------------- | +| customerId | string | Filter by customer ID | +| type | CartType | Filter by cart type | +| offset | number | Number of items to skip | +| limit | number | Maximum number of items to return | +| sort | string | Sorting criteria | + +### createCart + +Creates a new cart. + +```typescript +createCart( + data: CreateCartBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| ---------- | -------- | ---------------------------------------------- | +| currency | Currency | Cart currency (required) | +| regionId | string | Region ID for pricing/shipping (optional) | +| customerId | string | Customer ID for authenticated users (optional) | +| type | CartType | Cart type (default: ACTIVE) | +| metadata | object | Custom metadata (optional) | + +### addCartItem + +Adds an item to a cart. If no cartId is provided, finds active cart or creates a new one. + +```typescript +addCartItem( + data: AddCartItemBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| --------- | -------- | ------------------------------- | +| cartId | string | Existing cart ID (optional) | +| sku | string | Product variant SKU (required) | +| quantity | number | Quantity (required) | +| currency | Currency | Required when creating new cart | +| regionId | string | Required when creating new cart | + +### updateCartItem + +Updates the quantity of a cart item. + +```typescript +updateCartItem( + params: UpdateCartItemParams, + data: UpdateCartItemBody, + authorization?: string +): Observable +``` + +### removeCartItem + +Removes an item from the cart. + +```typescript +removeCartItem( + params: RemoveCartItemParams, + authorization?: string +): Observable +``` + +### updateCartAddresses + +Updates shipping and/or billing addresses on the cart (used during checkout). + +```typescript +updateCartAddresses( + params: UpdateCartAddressesParams, + data: UpdateCartAddressesBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| ----------------- | ------- | -------------------------------------------- | +| shippingAddressId | string | Use saved address (authenticated users only) | +| shippingAddress | Address | Or provide new address inline | +| billingAddressId | string | Use saved address (authenticated users only) | +| billingAddress | Address | Or provide new address inline | +| email | string | For guest checkout (optional) | + +### addShippingMethod + +Adds a shipping method to the cart. + +```typescript +addShippingMethod( + params: AddShippingMethodParams, + data: AddShippingMethodBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| ---------------- | ------ | ----------------------------------------------------- | +| shippingOptionId | string | Shipping option ID from Checkout.getShippingOptions() | + +### applyPromotion / removePromotion + +Applies or removes a promotion code from the cart. + +```typescript +applyPromotion(params: ApplyPromotionParams, data: ApplyPromotionBody, authorization?: string): Observable +removePromotion(params: RemovePromotionParams, authorization?: string): Observable +``` + +### getCurrentCart / prepareCheckout + +Retrieves the current active cart for the authenticated user, or prepares a cart for checkout. + +```typescript +getCurrentCart(authorization?: string): Observable +prepareCheckout(params: PrepareCheckoutParams, authorization?: string): Observable +``` + +## Data Model Structure + +```mermaid +classDiagram + class Cart { + } + + class CartItem { + } + + class CartType { + <> + } + + class PaymentMethod { + } + + class Promotion { + } + + class Product { + } + + class Address { + } + + class ShippingMethod { + } + + Cart "1" --> "0..*" CartItem : items + Cart "1" --> "0..1" Address : shippingAddress + Cart "1" --> "0..1" Address : billingAddress + Cart "1" --> "0..1" ShippingMethod : shippingMethod + Cart "1" --> "0..1" PaymentMethod : paymentMethod + Cart "1" --> "0..*" Promotion : promotions + Cart "1" --> "1" CartType : type + CartItem "1" --> "1" Product : product +``` + +The carts model supports: + +1. **Cart** — Container for items, addresses, shipping, payment +2. **CartItem** — Line item with product, quantity, pricing +3. **Guest checkout** — Carts without customerId; email required for order +4. **Promotions** — Discount codes with percentage, fixed amount, or free shipping + +## Types + +### Cart + +| Field | Type | Description | +| ---------------- | -------------- | ------------------------------------- | +| id | string | Unique identifier | +| customerId | string | Customer ID (optional for guests) | +| type | CartType | Cart type | +| currency | Currency | Cart currency | +| items | data, total | Line items with pagination | +| subtotal | Price | Subtotal before discounts (optional) | +| discountTotal | Price | Total discount (optional) | +| taxTotal | Price | Total tax (optional) | +| shippingTotal | Price | Shipping cost (optional) | +| total | Price | Grand total | +| shippingAddress | Address | Shipping address (optional) | +| billingAddress | Address | Billing address (optional) | +| shippingMethod | ShippingMethod | Selected shipping (optional) | +| paymentMethod | PaymentMethod | Selected payment (optional) | +| email | string | Guest email (optional) | +| paymentSessionId | string | Active payment session ref (optional) | +| createdAt | string | ISO 8601 timestamp | +| updatedAt | string | ISO 8601 timestamp | +| expiresAt | string | Expiration date (optional) | + +### CartItem + +| Field | Type | Description | +| ------------- | ------- | -------------------------------- | +| id | string | Unique identifier | +| sku | string | Product variant SKU | +| quantity | number | Quantity | +| price | Price | Unit price | +| subtotal | Price | Pre-discount subtotal (optional) | +| discountTotal | Price | Item discount (optional) | +| total | Price | Line total | +| product | Product | Product details | + +### PaymentMethod + +| Field | Type | Description | +| ----------- | ------ | ---------------------- | +| id | string | Unique identifier | +| name | string | Display name | +| description | string | Description (optional) | + +### Promotion + +| Field | Type | Description | +| ----- | ------------- | ---------------------------------------------- | +| id | string | Unique identifier | +| code | string | Promotion code | +| name | string | Display name (optional) | +| type | PromotionType | Percentage, fixed, or free shipping (optional) | +| value | string | Discount value (optional) | + +### CartType + +| Value | Description | +| --------- | -------------------- | +| ACTIVE | Active shopping cart | +| SAVED | Saved for later | +| ABANDONED | Abandoned cart | + +### PromotionType + +| Value | Description | +| ------------- | --------------------- | +| PERCENTAGE | Percentage discount | +| FIXED_AMOUNT | Fixed amount discount | +| FREE_SHIPPING | Free shipping | + +### Carts + +Paginated list of carts. + +```typescript +type Carts = Pagination.Paginated; +``` diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-checkout.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-checkout.md new file mode 100644 index 000000000..9ada2e077 --- /dev/null +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-checkout.md @@ -0,0 +1,217 @@ +--- +sidebar_position: 360 +--- + +# Checkout + +The checkout model orchestrates the multi-step process from cart to order: setting addresses, selecting shipping, creating payment sessions, and placing orders. The frontend controls the step flow; the API provides granular actions that can be called in any order. + +## Checkout Service + +The `CheckoutService` provides methods to complete checkout. + +### setAddresses + +Sets shipping and/or billing addresses on the cart. + +```typescript +setAddresses( + params: SetAddressesParams, + data: SetAddressesBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| ----------------- | ------- | -------------------------------------------- | +| shippingAddressId | string | Use saved address (authenticated users only) | +| shippingAddress | Address | Or provide new address inline | +| billingAddressId | string | Use saved address (authenticated users only) | +| billingAddress | Address | Or provide new address inline | +| notes | string | Order notes (optional) | +| email | string | Required for guest checkout | + +### setShippingMethod + +Selects a shipping option for the cart. + +```typescript +setShippingMethod( + params: SetShippingMethodParams, + data: SetShippingMethodBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| ---------------- | ------ | ---------------------------- | +| shippingOptionId | string | ID from getShippingOptions() | + +### setPayment + +Creates a payment session for the cart. + +```typescript +setPayment( + params: SetPaymentParams, + data: SetPaymentBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| ---------- | ------ | --------------------------------- | +| providerId | string | Payment provider ID (required) | +| metadata | object | Provider-specific data (optional) | + +#### Returns + +PaymentSession with redirectUrl (for redirect-based providers) or clientSecret (for embedded payments). + +### getShippingOptions + +Retrieves available shipping options for the cart. + +```typescript +getShippingOptions( + params: GetShippingOptionsParams, + authorization?: string +): Observable +``` + +#### Parameters + +| Parameter | Type | Description | +| --------- | ------ | ------------------------------------- | +| cartId | string | Cart ID (required) | +| locale | string | For localized option names (optional) | + +### getCheckoutSummary + +Retrieves the full checkout summary (cart with addresses, shipping, payment, totals). + +```typescript +getCheckoutSummary( + params: GetCheckoutSummaryParams, + authorization?: string +): Observable +``` + +### placeOrder + +Creates an order from the cart. Validates that addresses, shipping method, and payment session are present. + +```typescript +placeOrder( + params: PlaceOrderParams, + data?: PlaceOrderBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| --------- | ------ | --------------------------------------------------- | +| email | string | Required for guest checkout if not set in addresses | + +### completeCheckout + +One-shot flow: sets addresses, shipping, payment, and places the order in a single call. + +```typescript +completeCheckout( + params: CompleteCheckoutParams, + data: CompleteCheckoutBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| ----------------- | ------- | ------------------------------------ | +| shippingAddressId | string | Use saved address (authenticated) | +| shippingAddress | Address | Or provide new (required for guests) | +| billingAddressId | string | Use saved address (authenticated) | +| billingAddress | Address | Or provide new (required for guests) | +| shippingMethodId | string | Shipping option ID | +| paymentProviderId | string | Payment provider ID (required) | +| email | string | Required for guest checkout | +| notes | string | Order notes (optional) | + +## Data Model Structure + +```mermaid +classDiagram + class CheckoutSummary { + } + + class Cart { + } + + class Address { + } + + class ShippingMethod { + } + + class PaymentMethod { + } + + class PlaceOrderResponse { + } + + class Order { + } + + CheckoutSummary "1" --> "1" Cart : cart + CheckoutSummary "1" --> "1" Address : shippingAddress + CheckoutSummary "1" --> "1" Address : billingAddress + CheckoutSummary "1" --> "1" ShippingMethod : shippingMethod + CheckoutSummary "1" --> "1" PaymentMethod : paymentMethod + PlaceOrderResponse "1" --> "1" Order : order +``` + +The checkout flow: + +1. **setAddresses** — Cart must have items first; delegates to Carts.updateCartAddresses +2. **setShippingMethod** — Delegates to Carts.addShippingMethod +3. **setPayment** — Creates payment session; cart stores paymentSessionId +4. **placeOrder** — Validates required data; creates order from cart +5. **completeCheckout** — Orchestrates all steps in one call + +## Types + +### CheckoutSummary + +| Field | Type | Description | +| --------------- | -------------- | ---------------------------------------- | +| cart | Cart | Cart with items and metadata | +| shippingAddress | Address | Shipping address | +| billingAddress | Address | Billing address | +| shippingMethod | ShippingMethod | Selected shipping option | +| paymentMethod | PaymentMethod | Selected payment method | +| totals | object | subtotal, shipping, tax, discount, total | +| notes | string | Order notes (optional) | +| email | string | Guest email (optional) | + +### ShippingOptions + +| Field | Type | Description | +| ----- | ---------------- | -------------------------- | +| data | ShippingMethod[] | Available shipping options | +| total | number | Total number of options | + +### PlaceOrderResponse + +| Field | Type | Description | +| ------------------ | ------ | ----------------------------------- | +| order | Order | Created order | +| paymentRedirectUrl | string | Redirect URL for payment (optional) | diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-customers.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-customers.md new file mode 100644 index 000000000..ed64e255b --- /dev/null +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-customers.md @@ -0,0 +1,153 @@ +--- +sidebar_position: 370 +--- + +# Customers + +The customers model manages saved addresses for authenticated users. Customer addresses can be used during checkout (via shippingAddressId / billingAddressId) instead of entering inline addresses each time. + +:::info Authentication Required +All CustomerService methods require authentication. Guest users must provide addresses inline during checkout. +::: + +## Customer Service + +The `CustomerService` provides methods to manage customer addresses. + +### getAddresses + +Retrieves all saved addresses for the authenticated customer. + +```typescript +getAddresses(authorization?: string): Observable +``` + +#### Returns + +Paginated list of CustomerAddress. + +### getAddress + +Retrieves a specific address by ID. + +```typescript +getAddress( + params: GetAddressParams, + authorization?: string +): Observable +``` + +#### Parameters + +| Parameter | Type | Description | +| --------- | ------ | --------------------- | +| params.id | string | Address ID (required) | + +### createAddress + +Creates a new saved address. + +```typescript +createAddress( + data: CreateAddressBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| --------- | ------- | ------------------------------------ | +| label | string | Label e.g. "Home", "Work" (optional) | +| isDefault | boolean | Set as default address (optional) | +| address | Address | Address data (required) | + +### updateAddress + +Updates an existing address. + +```typescript +updateAddress( + params: UpdateAddressParams, + data: UpdateAddressBody, + authorization?: string +): Observable +``` + +### deleteAddress + +Deletes a saved address. + +```typescript +deleteAddress( + params: DeleteAddressParams, + authorization?: string +): Observable +``` + +### setDefaultAddress + +Sets an address as the default for the customer. + +```typescript +setDefaultAddress( + params: SetDefaultAddressParams, + authorization?: string +): Observable +``` + +## Data Model Structure + +```mermaid +classDiagram + class CustomerAddress { + } + + class Address { + } + + CustomerAddress "1" --> "1" Address : address +``` + +Saved addresses are used during checkout when the customer selects a previously saved address instead of entering a new one. + +## Types + +### CustomerAddress + +| Field | Type | Description | +| ---------- | ------- | ------------------------------------ | +| id | string | Unique identifier | +| customerId | string | Customer ID | +| label | string | Label e.g. "Home", "Work" (optional) | +| isDefault | boolean | Is default address (optional) | +| address | Address | Address data | +| createdAt | string | ISO 8601 timestamp | +| updatedAt | string | ISO 8601 timestamp | + +### Address + +Shared address type used in carts, checkout, orders, and customer addresses. + +| Field | Type | Description | +| ------------ | ------ | ------------------------ | +| firstName | string | First name (optional) | +| lastName | string | Last name (optional) | +| country | string | Country code (required) | +| district | string | District (optional) | +| region | string | Region (optional) | +| streetName | string | Street name (required) | +| streetNumber | string | Street number (optional) | +| apartment | string | Apartment (optional) | +| city | string | City (required) | +| postalCode | string | Postal code (required) | +| email | string | Email (optional) | +| phone | string | Phone (optional) | + +### CustomerAddresses + +Paginated list of customer addresses. + +```typescript +type CustomerAddresses = Pagination.Paginated; +``` diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-orders.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-orders.md new file mode 100644 index 000000000..c4bd2754d --- /dev/null +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-orders.md @@ -0,0 +1,216 @@ +--- +sidebar_position: 380 +--- + +# Orders + +The orders model represents completed purchases. Orders are created from carts via the checkout flow (Checkout.placeOrder or completeCheckout), not directly. + +## Order Service + +The `OrderService` provides methods to retrieve order data. + +### getOrder + +Retrieves a specific order by ID. + +```typescript +getOrder( + params: GetOrderParams, + authorization?: string +): Observable +``` + +#### Parameters + +| Parameter | Type | Description | +| ------------- | -------------- | ------------------------------ | +| params | GetOrderParams | Parameters containing order ID | +| authorization | string | Optional authorization header | + +#### Params Parameters + +| Parameter | Type | Description | +| --------- | ------ | ------------------- | +| id | string | Order ID (required) | + +#### Returns + +An Observable that emits the requested order or undefined if not found. + +### getOrderList + +Retrieves a paginated list of orders with optional filtering. + +```typescript +getOrderList( + query: GetOrderListQuery, + authorization?: string +): Observable +``` + +#### Query Parameters + +| Parameter | Type | Description | +| ------------- | ------------- | --------------------------------- | +| id | string | Filter by order ID | +| customerId | string | Filter by customer ID | +| status | OrderStatus | Filter by order status | +| paymentStatus | PaymentStatus | Filter by payment status | +| dateFrom | Date | Filter by creation date (from) | +| dateTo | Date | Filter by creation date (to) | +| offset | number | Number of items to skip | +| limit | number | Maximum number of items to return | +| sort | string | Sorting criteria | +| locale | string | Locale for localized content | + +#### Returns + +An Observable that emits a paginated list of orders. + +## Data Model Structure + +```mermaid +classDiagram + class Order { + } + + class OrderItem { + } + + class OrderStatus { + <> + } + + class PaymentStatus { + <> + } + + class ShippingMethod { + } + + class Document { + } + + class Product { + } + + class Address { + } + + Order "1" --> "0..*" OrderItem : items + Order "1" --> "0..*" ShippingMethod : shippingMethods + Order "1" --> "0..*" Document : documents + Order "1" --> "0..1" Address : shippingAddress + Order "1" --> "0..1" Address : billingAddress + Order "1" --> "1" OrderStatus : status + Order "1" --> "1" PaymentStatus : paymentStatus + OrderItem "1" --> "1" Product : product +``` + +Orders are created from carts via checkout. They contain: + +1. **Order** — Header with totals, status, addresses, shipping methods +2. **OrderItem** — Line items with product, quantity, pricing +3. **Document** — Invoices or other documents (optional) + +## Types + +### Order + +| Field | Type | Description | +| ------------------- | ---------------- | --------------------------------- | +| id | string | Unique identifier | +| customerId | string | Customer ID (optional for guests) | +| email | string | Guest email (optional) | +| status | OrderStatus | Order status | +| paymentStatus | PaymentStatus | Payment status | +| total | Price | Grand total | +| subtotal | Price | Subtotal (optional) | +| shippingTotal | Price | Shipping cost (optional) | +| discountTotal | Price | Discount (optional) | +| tax | Price | Tax (optional) | +| currency | Currency | Order currency | +| items | data, total | Line items | +| shippingAddress | Address | Shipping address (optional) | +| billingAddress | Address | Billing address (optional) | +| shippingMethods | ShippingMethod[] | Shipping methods used | +| documents | Document[] | Invoices etc. (optional) | +| customerComment | string | Customer comment (optional) | +| purchaseOrderNumber | string | PO number (optional) | +| createdAt | string | ISO 8601 timestamp | +| updatedAt | string | ISO 8601 timestamp | +| paymentDueDate | string | Payment due date (optional) | + +### OrderItem + +| Field | Type | Description | +| ------------- | ------- | -------------------------------- | +| id | string | Unique identifier | +| productId | string | Product ID | +| quantity | number | Quantity | +| price | Price | Unit price | +| total | Price | Line total (optional) | +| subtotal | Price | Pre-discount subtotal (optional) | +| discountTotal | Price | Item discount (optional) | +| product | Product | Product details | + +### ShippingMethod + +| Field | Type | Description | +| ----------- | ------ | ------------------------ | +| id | string | Unique identifier | +| name | string | Display name | +| description | string | Description (optional) | +| total | Price | Shipping cost (optional) | +| subtotal | Price | Subtotal (optional) | + +### Document + +Represents an invoice or other order document. + +| Field | Type | Description | +| --------- | ------------- | ------------------ | +| id | string | Unique identifier | +| orderId | string | Order ID | +| type | string | Document type | +| status | PaymentStatus | Payment status | +| toBePaid | Price | Amount to be paid | +| total | Price | Total amount | +| dueDate | string | Due date | +| createdAt | string | ISO 8601 timestamp | +| updatedAt | string | ISO 8601 timestamp | + +### OrderStatus + +| Value | Description | +| --------------- | ------------------------ | +| PENDING | Order pending processing | +| COMPLETED | Order completed | +| SHIPPED | Order shipped | +| CANCELLED | Order cancelled | +| ARCHIVED | Order archived | +| REQUIRES_ACTION | Requires customer action | +| UNKNOWN | Unknown status | + +### PaymentStatus + +| Value | Description | +| ------------------ | ------------------------ | +| PENDING | Payment pending | +| PAID | Payment completed | +| FAILED | Payment failed | +| REFUNDED | Fully refunded | +| NOT_PAID | Not paid | +| CAPTURED | Payment captured | +| PARTIALLY_REFUNDED | Partially refunded | +| REQUIRES_ACTION | Requires customer action | +| UNKNOWN | Unknown status | + +### Orders + +Paginated list of orders. + +```typescript +type Orders = Pagination.Paginated; +``` diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-payments.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-payments.md new file mode 100644 index 000000000..e56a24dad --- /dev/null +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-payments.md @@ -0,0 +1,154 @@ +--- +sidebar_position: 370 +--- + +# Payments + +The payments model provides payment provider management and payment session handling for checkout flows. It supports multiple payment providers (Stripe, PayPal, etc.) and manages payment sessions that link carts to payment processing. + +## Payment Service + +The `PaymentService` provides methods to interact with payment providers and sessions. + +### getProviders + +Retrieves available payment providers for a region. + +```typescript +getProviders( + params: GetProvidersParams, + authorization?: string +): Observable +``` + +#### Parameters + +| Parameter | Type | Description | +| --------- | ------ | -------------------- | +| regionId | string | Region ID (required) | + +#### Returns + +An Observable that emits a paginated list of payment providers. + +### createSession + +Creates a payment session for a cart. + +```typescript +createSession( + data: CreateSessionBody, + authorization?: string +): Observable +``` + +#### Body Parameters + +| Parameter | Type | Description | +| ---------- | ------ | ------------------------------ | +| cartId | string | Cart ID (required) | +| providerId | string | Payment provider ID (required) | + +#### Returns + +PaymentSession with redirectUrl (for redirect-based providers) or clientSecret (for embedded payments). + +### getSession + +Retrieves a payment session by ID. + +```typescript +getSession( + params: GetSessionParams, + authorization?: string +): Observable +``` + +### updateSession + +Updates a payment session (e.g., to change return URL). + +```typescript +updateSession( + params: UpdateSessionParams, + data: UpdateSessionBody, + authorization?: string +): Observable +``` + +### cancelSession + +Cancels (deletes) a payment session. + +```typescript +cancelSession( + params: CancelSessionParams, + authorization?: string +): Observable +``` + +## Data Model Structure + +```mermaid +classDiagram + class PaymentProvider { + } + + class PaymentSession { + } + + class Cart { + } + + PaymentSession "1" --> "1" PaymentProvider : providerId + PaymentSession "1" --> "1" Cart : cartId +``` + +The payments model supports: + +1. **PaymentProvider** — Available payment providers (Stripe, PayPal, etc.) +2. **PaymentSession** — Active payment sessions linking carts to payment processing + +## Types + +### PaymentProvider + +| Field | Type | Description | +| ---------------- | ------- | ---------------------------------------------------------- | +| id | string | Unique identifier | +| name | string | Display name | +| type | string | Provider type (e.g., "STRIPE", "PAYPAL") | +| isEnabled | boolean | Whether the provider is enabled | +| requiresRedirect | boolean | Whether provider requires redirect (e.g., Stripe Checkout) | +| config | object | Provider-specific configuration (optional) | + +### PaymentSession + +| Field | Type | Description | +| ------------ | -------------------- | ---------------------------------------------------- | +| id | string | Unique identifier | +| cartId | string | Associated cart ID | +| providerId | string | Payment provider ID | +| status | PaymentSessionStatus | Session status | +| redirectUrl | string | Redirect URL for redirect-based providers (optional) | +| clientSecret | string | Client secret for embedded payment forms (optional) | +| expiresAt | string | Expiration date (optional) | +| metadata | object | Additional metadata (optional) | + +### PaymentSessionStatus + +| Value | Description | +| ---------- | ------------------ | +| PENDING | Payment pending | +| AUTHORIZED | Payment authorized | +| CAPTURED | Payment captured | +| FAILED | Payment failed | +| CANCELLED | Payment cancelled | + +### PaymentProviders + +Paginated list of payment providers. + +```typescript +type PaymentProviders = Pagination.Paginated; +``` diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-products.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-products.md index 5aa91785f..f06ae196b 100644 --- a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-products.md +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-products.md @@ -4,7 +4,7 @@ sidebar_position: 300 # Products -The products model represents the structure of product catalog items and their related information in the system. This model enables management of product listings, variants, specifications, and related product relationships. +The products model represents the structure of product catalog items and their related information in the system. This model enables management of product listings, variants, specifications, and related product relationships. The Product type is referenced by [Carts](./core-model-carts.md) (CartItem) and [Orders](./core-model-orders.md) (OrderItem). ## Product Service diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-services.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-services.md index 06b8d35bc..2c491a242 100644 --- a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-services.md +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-services.md @@ -2,7 +2,117 @@ sidebar_position: 600 --- -# Services +# Resources (Assets & Services) + +The resources model represents customer-owned assets (tangible equipment) and service subscriptions. Assets can have warranty tracking and compatible services; Services have contracts with payment periods. This module supports after-sales scenarios like equipment management and subscription renewal. + +:::info Plugin Required +The Resources module requires the [medusa-plugin-assets-services](https://github.com/o2sdev/medusa-plugin-assets-services) plugin when using the Medusa.js integration. +::: + +## Resource Service + +The `ResourceService` provides methods to interact with assets and services. + +### getServiceList + +Retrieves a paginated list of service subscriptions for the authenticated customer. + +```typescript +getServiceList( + query: GetServiceListQuery, + authorization: string +): Observable +``` + +#### Query Parameters + +| Parameter | Type | Description | +| ---------------- | -------------- | --------------------------------- | +| status | ContractStatus | Filter by contract status | +| type | ProductType | Filter by product type | +| category | string | Filter by category | +| billingAccountId | string | Filter by billing account | +| dateFrom | string | Filter by start date | +| dateTo | string | Filter by end date | +| offset | number | Number of items to skip | +| limit | number | Maximum number of items to return | +| sort | string | Sorting criteria | +| locale | string | Locale for localized content | + +### getService + +Retrieves a specific service by ID. + +```typescript +getService( + params: GetServiceParams, + authorization?: string +): Observable +``` + +### getAssetList + +Retrieves a paginated list of assets (customer equipment) for the authenticated customer. + +```typescript +getAssetList( + query: GetAssetListQuery, + authorization: string +): Observable +``` + +#### Query Parameters + +| Parameter | Type | Description | +| ---------------- | ----------- | --------------------------------- | +| status | string | Filter by asset status | +| type | ProductType | Filter by product type | +| billingAccountId | string | Filter by billing account | +| dateFrom | string | Filter by date | +| dateTo | string | Filter by date | +| offset | number | Number of items to skip | +| limit | number | Maximum number of items to return | +| sort | string | Sorting criteria | +| locale | string | Locale for localized content | + +### getAsset + +Retrieves a specific asset by ID. + +```typescript +getAsset( + params: GetAssetParams, + authorization?: string +): Observable +``` + +### getCompatibleServiceList + +Retrieves services that can be purchased for a specific asset (upsell/cross-sell). + +```typescript +getCompatibleServiceList(params: GetAssetParams): Observable +``` + +### getFeaturedServiceList + +Retrieves featured or promoted services. + +```typescript +getFeaturedServiceList(): Observable +``` + +### purchaseOrActivateResource / purchaseOrActivateService + +Initiates purchase or activation of a resource or service. Not fully implemented in all integrations. + +```typescript +purchaseOrActivateResource(params: GetResourceParams, authorization?: string): Observable +purchaseOrActivateService(params: GetServiceParams, authorization?: string): Observable +``` + +## Data Model Structure ```mermaid classDiagram @@ -25,8 +135,7 @@ classDiagram class Product { } - note for Product "Asset: Tangible products you can purchase, own, manufacture, store, and transport - Service: Intangible offerings (e.g., consulting, subscription)" + note for Product "Asset: Tangible products you can purchase, own, manufacture, store, and transport. Service: Intangible offerings e.g. consulting, subscription" class ProductType { <> @@ -55,5 +164,90 @@ classDiagram Product "*" --> "1" ProductType Contract "1" --> "1" ContractStatus Asset "1" --> "1" AssetStatus +``` + +- **Resource** — Base for Asset and Service; links to Product and BillingAccount +- **Asset** — Customer-owned equipment (serial no, warranty, compatible services) +- **Service** — Subscription with Contract (start/end date, payment period) + +## Types + +### Resource + +Base type for assets and services. + +| Field | Type | Description | +| ---------------- | ------- | ------------------ | +| id | string | Unique identifier | +| product | Product | Associated product | +| billingAccountId | string | Billing account ID | + +### Asset + +Customer-owned tangible equipment. Extends Resource. + +| Field | Type | Description | +| ------------------ | ----------- | ----------------------------------------- | +| manufacturer | string | Manufacturer (optional) | +| model | string | Model name | +| serialNo | string | Serial number | +| description | string | Description | +| status | AssetStatus | Asset status (optional) | +| address | Address | Installation/location (optional) | +| endOfWarranty | string | Warranty end date (optional) | +| compatibleServices | Products | Services that can be purchased (optional) | + +### Service + +Service subscription. Extends Resource. + +| Field | Type | Description | +| -------- | -------- | ------------------------ | +| contract | Contract | Contract details | +| assets | Asset[] | Linked assets (optional) | + +### Contract + +| Field | Type | Description | +| ------------- | -------------- | ---------------------------- | +| id | string | Unique identifier | +| type | string | Contract type (optional) | +| status | ContractStatus | Contract status | +| startDate | string | Start date | +| endDate | string | End date | +| paymentPeriod | PaymentPeriod | Billing frequency (optional) | +| price | Price | Contract price | + +### AssetStatus + +| Value | Description | +| -------- | ------------ | +| ACTIVE | Active asset | +| INACTIVE | Inactive | +| RETIRED | Retired | + +### ContractStatus + +| Value | Description | +| -------- | --------------- | +| ACTIVE | Active contract | +| EXPIRED | Expired | +| INACTIVE | Inactive | + +### PaymentPeriod + +| Value | Description | +| -------- | ----------- | +| ONE_TIME | One-time | +| MONTHLY | Monthly | +| YEARLY | Yearly | +| WEEKLY | Weekly | + +### Services / Assets + +Paginated lists. +```typescript +type Services = Pagination.Paginated; +type Assets = Pagination.Paginated; ``` diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-tickets.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-tickets.md index 93bc6e031..216df94cd 100644 --- a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-tickets.md +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-tickets.md @@ -116,15 +116,16 @@ createTicket( #### Body Parameters -| Parameter | Type | Required | Description | -| ----------- | ----------------------- | -------- | ------------------------------------------------------------ | -| title | string | No | Title or subject of the ticket | -| description | string | No | Detailed description of the issue | -| type | number | No | Ticket type identifier (e.g., form ID in ticket systems) | -| attachments | TicketAttachmentInput[] | No | Array of file attachments | -| fields | object | No | Additional custom fields specific to the integration | +| Parameter | Type | Required | Description | +| ----------- | ----------------------- | -------- | -------------------------------------------------------- | +| title | string | No | Title or subject of the ticket | +| description | string | No | Detailed description of the issue | +| type | number | No | Ticket type identifier (e.g., form ID in ticket systems) | +| attachments | TicketAttachmentInput[] | No | Array of file attachments | +| fields | object | No | Additional custom fields specific to the integration | > **Note**: While `description` and `type` are marked as optional in the core model, certain integrations require these fields: +> > - **Zendesk**: Requires both `description` (string) and `type` (number, ticket form ID) > - **SurveyJS**: Requires `description` (string) and `ticketFormId` (number, mapped to `type`) > diff --git a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/overview.md b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/overview.md index 2fd46701e..8084fe337 100644 --- a/apps/docs/docs/main-components/harmonization-app/normalized-data-model/overview.md +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/overview.md @@ -13,10 +13,16 @@ This section will give you an overview on: We will cover every module that is currently supported within the O2S: -- [Organization](./core-model-organization.md) - fetch user's data within their organization hierarchy, -- [Products](./core-model-products.md) - fetch product catalog data, variants, and related products, -- [Tickets](./core-model-tickets.md) - fetch and submit user tickets, which can cover a variety of cases, -- [Financial](./core-model-financial.md) - fetch user's invoices, and handle their payment, +- [Carts](./core-model-carts.md) - cart management, line items, addresses, shipping +- [Checkout](./core-model-checkout.md) - multi-step checkout and order placement +- [Payments](./core-model-payments.md) - payment providers and payment sessions +- [Customers](./core-model-customers.md) - customer address management +- [Orders](./core-model-orders.md) - order history and details +- [Products](./core-model-products.md) - product catalog data, variants, and related products +- [Resources](./core-model-services.md) - assets and services (customer equipment, subscriptions) +- [Organization](./core-model-organization.md) - fetch user's data within their organization hierarchy +- [Tickets](./core-model-tickets.md) - fetch and submit user tickets, which can cover a variety of cases +- [Financial](./core-model-financial.md) - fetch user's invoices, and handle their payment :::info You can check our [roadmap](/blog/roadmap) for more information about the upcoming modules. diff --git a/package.json b/package.json index 46ffc3cca..e60d1db0d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test": "turbo test && vitest run --project=storybook", "view-report": "turbo run view-report", "generate": "turbo gen", + "generate:postman-collection": "node scripts/generate-postman-collection.mjs", "eject-block": "ts-node ./scripts/cli.ts eject-block", "docs": "turbo start --filter=@o2s/docs", "changeset": "changeset", diff --git a/packages/blocks/product-details/src/api-harmonization/product-details.service.ts b/packages/blocks/product-details/src/api-harmonization/product-details.service.ts index 321058a7b..8324c07ef 100644 --- a/packages/blocks/product-details/src/api-harmonization/product-details.service.ts +++ b/packages/blocks/product-details/src/api-harmonization/product-details.service.ts @@ -30,13 +30,16 @@ export class ProductDetailsService { return cms.pipe( switchMap((cmsData) => { - const product = this.productsService.getProduct({ - id, - variantId: variantSlug, - locale, - basePath: cmsData.basePath, - variantOptionGroups: cmsData.variantOptionGroups, - }); + const product = this.productsService.getProduct( + { + id, + variantId: variantSlug, + locale, + basePath: cmsData.basePath, + variantOptionGroups: cmsData.variantOptionGroups, + }, + headers['authorization'], + ); return forkJoin([of(cmsData), product]).pipe( map(([cms, product]) => { diff --git a/packages/blocks/product-list/src/api-harmonization/product-list.service.ts b/packages/blocks/product-list/src/api-harmonization/product-list.service.ts index d6bb0790e..e777ab8ab 100644 --- a/packages/blocks/product-list/src/api-harmonization/product-list.service.ts +++ b/packages/blocks/product-list/src/api-harmonization/product-list.service.ts @@ -27,35 +27,19 @@ export class ProductListService { return forkJoin([cms]).pipe( concatMap(([cms]) => { return this.productsService - .getProductList({ - ...query, - limit: query.limit || cms.pagination?.limit || 12, - offset: query.offset || 0, - type: 'PHYSICAL' as Products.Model.ProductType, - category: query.category, - locale: headers['x-locale'], - basePath: cms.basePath, - }) - .pipe( - map((products) => { - const result = mapProductList(products, cms, headers['x-locale']); - - // Extract permissions using ACL service - if (headers.authorization) { - const permissions = this.authService.canPerformActions( - headers.authorization, - 'products', - ['view'], - ); - - result.permissions = { - view: permissions.view ?? false, - }; - } - - return result; - }), - ); + .getProductList( + { + ...query, + limit: query.limit || cms.pagination?.limit || 12, + offset: query.offset || 0, + type: 'PHYSICAL' as Products.Model.ProductType, + category: query.category, + locale: headers['x-locale'], + basePath: cms.basePath, + }, + headers['authorization'], + ) + .pipe(map((products) => mapProductList(products, cms, headers['x-locale']))); }), ); } diff --git a/packages/blocks/product-list/src/frontend/ProductList.server.tsx b/packages/blocks/product-list/src/frontend/ProductList.server.tsx index ed44298c7..12d1a7dc5 100644 --- a/packages/blocks/product-list/src/frontend/ProductList.server.tsx +++ b/packages/blocks/product-list/src/frontend/ProductList.server.tsx @@ -24,10 +24,5 @@ export const ProductList: React.FC = async ({ id, accessToken, return null; } - // Check view permission - if not allowed, don't render - if (!data.permissions?.view) { - return null; - } - return ; }; diff --git a/packages/configs/integrations/src/models/carts.ts b/packages/configs/integrations/src/models/carts.ts new file mode 100644 index 000000000..b8f1582ed --- /dev/null +++ b/packages/configs/integrations/src/models/carts.ts @@ -0,0 +1,9 @@ +import { Config, Integration } from '@o2s/integrations.mocked/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const CartsIntegrationConfig: ApiConfig['integrations']['carts'] = Config.carts!; + +export import Service = Integration.Carts.Service; +export import Request = Integration.Carts.Request; +export import Model = Integration.Carts.Model; diff --git a/packages/configs/integrations/src/models/checkout.ts b/packages/configs/integrations/src/models/checkout.ts new file mode 100644 index 000000000..e267d6b5c --- /dev/null +++ b/packages/configs/integrations/src/models/checkout.ts @@ -0,0 +1,9 @@ +import { Config, Integration } from '@o2s/integrations.mocked/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const CheckoutIntegrationConfig: ApiConfig['integrations']['checkout'] = Config.checkout!; + +export import Service = Integration.Checkout.Service; +export import Request = Integration.Checkout.Request; +export import Model = Integration.Checkout.Model; diff --git a/packages/configs/integrations/src/models/customers.ts b/packages/configs/integrations/src/models/customers.ts new file mode 100644 index 000000000..80f726acf --- /dev/null +++ b/packages/configs/integrations/src/models/customers.ts @@ -0,0 +1,9 @@ +import { Config, Integration } from '@o2s/integrations.mocked/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const CustomersIntegrationConfig: ApiConfig['integrations']['customers'] = Config.customers!; + +export import Service = Integration.Customers.Service; +export import Request = Integration.Customers.Request; +export import Model = Integration.Customers.Model; diff --git a/packages/configs/integrations/src/models/index.ts b/packages/configs/integrations/src/models/index.ts index c23e92ba1..8cf0413f4 100644 --- a/packages/configs/integrations/src/models/index.ts +++ b/packages/configs/integrations/src/models/index.ts @@ -2,11 +2,15 @@ export * as Articles from './articles'; export * as Auth from './auth'; export * as BillingAccounts from './billing-accounts'; export * as Cache from './cache'; +export * as Carts from './carts'; +export * as Checkout from './checkout'; export * as CMS from './cms'; +export * as Customers from './customers'; export * as Invoices from './invoices'; export * as Notifications from './notifications'; export * as Orders from './orders'; export * as Organizations from './organizations'; +export * as Payments from './payments'; export * as Products from './products'; export * as Resources from './resources'; export * as Search from './search'; diff --git a/packages/configs/integrations/src/models/payments.ts b/packages/configs/integrations/src/models/payments.ts new file mode 100644 index 000000000..b03c77fa5 --- /dev/null +++ b/packages/configs/integrations/src/models/payments.ts @@ -0,0 +1,9 @@ +import { Config, Integration } from '@o2s/integrations.mocked/integration'; + +import { ApiConfig } from '@o2s/framework/modules'; + +export const PaymentsIntegrationConfig: ApiConfig['integrations']['payments'] = Config.payments!; + +export import Service = Integration.Payments.Service; +export import Request = Integration.Payments.Request; +export import Model = Integration.Payments.Model; diff --git a/packages/framework/src/api-config.ts b/packages/framework/src/api-config.ts index 17ea6e951..5e72e2661 100644 --- a/packages/framework/src/api-config.ts +++ b/packages/framework/src/api-config.ts @@ -6,10 +6,14 @@ import { BillingAccounts, CMS, Cache, + Carts, + Checkout, + Customers, Invoices, Notifications, Orders, Organizations, + Payments, Products, Resources, Search, @@ -98,6 +102,30 @@ export interface ApiConfig { controller?: typeof Orders.Controller; imports?: Type[]; }; + carts: { + name: string; + service: typeof Carts.Service; + controller?: typeof Carts.Controller; + imports?: Type[]; + }; + customers: { + name: string; + service: typeof Customers.Service; + controller?: typeof Customers.Controller; + imports?: Type[]; + }; + payments: { + name: string; + service: typeof Payments.Service; + controller?: typeof Payments.Controller; + imports?: Type[]; + }; + checkout: { + name: string; + service: typeof Checkout.Service; + controller?: typeof Checkout.Controller; + imports?: Type[]; + }; auth: { name: string; service: typeof Auth.Service; diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index d98d983e1..4aba415ca 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -16,3 +16,7 @@ export * as BillingAccounts from './modules/billing-accounts'; export * as Search from './modules/search'; export * as Products from './modules/products'; export * as Orders from './modules/orders'; +export * as Carts from './modules/carts'; +export * as Customers from './modules/customers'; +export * as Payments from './modules/payments'; +export * as Checkout from './modules/checkout'; diff --git a/packages/framework/src/modules/carts/carts.controller.ts b/packages/framework/src/modules/carts/carts.controller.ts new file mode 100644 index 000000000..de6342280 --- /dev/null +++ b/packages/framework/src/modules/carts/carts.controller.ts @@ -0,0 +1,82 @@ +import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query, UseInterceptors } from '@nestjs/common'; + +import { LoggerService } from '@o2s/utils.logger'; + +import { Request } from './'; +import { CartService } from './carts.service'; +import { AppHeaders } from '@/utils/models/headers'; + +@Controller('/carts') +@UseInterceptors(LoggerService) +export class CartsController { + constructor(protected readonly cartService: CartService) {} + + @Get('current') + getCurrentCart(@Headers() headers: AppHeaders) { + return this.cartService.getCurrentCart(headers.authorization); + } + + @Get(':id') + getCart(@Param() params: Request.GetCartParams, @Headers() headers: AppHeaders) { + return this.cartService.getCart(params, headers.authorization); + } + + @Get() + getCartList(@Query() query: Request.GetCartListQuery, @Headers() headers: AppHeaders) { + return this.cartService.getCartList(query, headers.authorization); + } + + @Post() + createCart(@Body() body: Request.CreateCartBody, @Headers() headers: AppHeaders) { + return this.cartService.createCart(body, headers.authorization); + } + + @Patch(':id') + updateCart( + @Param() params: Request.UpdateCartParams, + @Body() body: Request.UpdateCartBody, + @Headers() headers: AppHeaders, + ) { + return this.cartService.updateCart(params, body, headers.authorization); + } + + @Delete(':id') + deleteCart(@Param() params: Request.DeleteCartParams, @Headers() headers: AppHeaders) { + return this.cartService.deleteCart(params, headers.authorization); + } + + // Cart item operations + @Post('items') + addCartItem(@Body() body: Request.AddCartItemBody, @Headers() headers: AppHeaders) { + return this.cartService.addCartItem(body, headers.authorization); + } + + @Patch(':cartId/items/:itemId') + updateCartItem( + @Param() params: Request.UpdateCartItemParams, + @Body() body: Request.UpdateCartItemBody, + @Headers() headers: AppHeaders, + ) { + return this.cartService.updateCartItem(params, body, headers.authorization); + } + + @Delete(':cartId/items/:itemId') + removeCartItem(@Param() params: Request.RemoveCartItemParams, @Headers() headers: AppHeaders) { + return this.cartService.removeCartItem(params, headers.authorization); + } + + // Promotion operations + @Post(':cartId/promotions') + applyPromotion( + @Param() params: Request.ApplyPromotionParams, + @Body() body: Request.ApplyPromotionBody, + @Headers() headers: AppHeaders, + ) { + return this.cartService.applyPromotion(params, body, headers.authorization); + } + + @Delete(':cartId/promotions/:code') + removePromotion(@Param() params: Request.RemovePromotionParams, @Headers() headers: AppHeaders) { + return this.cartService.removePromotion(params, headers.authorization); + } +} diff --git a/packages/framework/src/modules/carts/carts.model.ts b/packages/framework/src/modules/carts/carts.model.ts new file mode 100644 index 000000000..338b0c7b9 --- /dev/null +++ b/packages/framework/src/modules/carts/carts.model.ts @@ -0,0 +1,66 @@ +import { ShippingMethod } from '../orders/orders.model'; +import { Product } from '../products/products.model'; + +import { Address, Pagination, Price, Unit } from '@/utils/models'; + +export type PromotionType = 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_SHIPPING'; + +export class PaymentMethod { + id!: string; + name!: string; + description?: string; +} + +export class Promotion { + id!: string; + code!: string; + name?: string; + description?: string; + type?: PromotionType; + value?: string; +} + +export class CartItem { + id!: string; + sku!: string; + quantity!: number; + price!: Price.Price; + subtotal?: Price.Price; + discountTotal?: Price.Price; + total!: Price.Price; + unit?: Unit.Unit; + currency!: Price.Currency; + product!: Product; + metadata?: Record; +} + +export class Cart { + id!: string; + customerId?: string; + name?: string; + createdAt!: string; + updatedAt!: string; + expiresAt?: string; + regionId?: string; + currency!: Price.Currency; + items!: { + data: CartItem[]; + total: number; + }; + subtotal?: Price.Price; + discountTotal?: Price.Price; + taxTotal?: Price.Price; + shippingTotal?: Price.Price; + total!: Price.Price; + shippingAddress?: Address.Address; + billingAddress?: Address.Address; + shippingMethod?: ShippingMethod; + paymentMethod?: PaymentMethod; + promotions?: Promotion[]; + metadata?: Record; + notes?: string; + email?: string; // For guest checkout + paymentSessionId?: string; // Reference to active payment session +} + +export type Carts = Pagination.Paginated; diff --git a/packages/framework/src/modules/carts/carts.module.ts b/packages/framework/src/modules/carts/carts.module.ts new file mode 100644 index 000000000..203651a01 --- /dev/null +++ b/packages/framework/src/modules/carts/carts.module.ts @@ -0,0 +1,29 @@ +import { HttpModule } from '@nestjs/axios'; +import { DynamicModule, Global, Module, Type } from '@nestjs/common'; + +import { CartsController } from './carts.controller'; +import { CartService } from './carts.service'; +import { ApiConfig } from '@/api-config'; + +@Global() +@Module({}) +export class CartsModule { + static register(config: ApiConfig): DynamicModule { + const service = config.integrations.carts.service; + const controller = config.integrations.carts.controller || CartsController; + const imports = config.integrations.carts.imports || []; + + const provider = { + provide: CartService, + useClass: service as Type, + }; + + return { + module: CartsModule, + providers: [provider], + imports: [HttpModule, ...imports], + controllers: [controller], + exports: [provider], + }; + } +} diff --git a/packages/framework/src/modules/carts/carts.request.ts b/packages/framework/src/modules/carts/carts.request.ts new file mode 100644 index 000000000..f12cc738b --- /dev/null +++ b/packages/framework/src/modules/carts/carts.request.ts @@ -0,0 +1,116 @@ +import { Address, Price } from '@/utils/models'; +import { PaginationQuery } from '@/utils/models/pagination'; + +// Cart retrieval +export class GetCartParams { + id!: string; +} + +export class GetCartListQuery extends PaginationQuery { + customerId?: string; + sort?: string; + locale?: string; +} + +// Cart creation and updates +export class CreateCartBody { + customerId?: string; + name?: string; + regionId?: string; + currency!: Price.Currency; + metadata?: Record; + locale?: string; +} + +export class UpdateCartParams { + id!: string; +} + +export class UpdateCartBody { + name?: string; + regionId?: string; + email?: string; // For guest checkout (passed directly to cart, not metadata) + shippingAddressId?: string; + billingAddressId?: string; + shippingMethodId?: string; + paymentMethodId?: string; + notes?: string; + metadata?: Record; + locale?: string; +} + +export class DeleteCartParams { + id!: string; +} + +// Cart item operations +export class AddCartItemBody { + cartId?: string; // Optional - if provided, use existing cart; if not, auto-create/find active cart + sku!: string; + quantity!: number; + currency?: Price.Currency; // Required if creating new cart + regionId?: string; // Required if creating new cart (for Medusa.js) + metadata?: Record; + locale?: string; +} + +export class UpdateCartItemParams { + cartId!: string; + itemId!: string; +} + +export class UpdateCartItemBody { + quantity?: number; + metadata?: Record; + locale?: string; +} + +export class RemoveCartItemParams { + cartId!: string; + itemId!: string; +} + +// Promotion operations +export class ApplyPromotionParams { + cartId!: string; +} + +export class ApplyPromotionBody { + code!: string; + locale?: string; +} + +export class RemovePromotionParams { + cartId!: string; + code!: string; +} + +// Checkout operations +export class PrepareCheckoutParams { + cartId!: string; +} + +// Address operations +export class UpdateCartAddressesParams { + cartId!: string; +} + +export class UpdateCartAddressesBody { + shippingAddressId?: string; // Use saved address (authenticated users only) + shippingAddress?: Address.Address; // Or provide new address + billingAddressId?: string; // Use saved address (authenticated users only) + billingAddress?: Address.Address; // Or provide new address + notes?: string; + email?: string; // For guest checkout + locale?: string; +} + +// Shipping method operations +export class AddShippingMethodParams { + cartId!: string; +} + +export class AddShippingMethodBody { + shippingOptionId!: string; // Shipping option ID from getShippingOptions() + locale?: string; +} diff --git a/packages/framework/src/modules/carts/carts.service.ts b/packages/framework/src/modules/carts/carts.service.ts new file mode 100644 index 000000000..1604efe76 --- /dev/null +++ b/packages/framework/src/modules/carts/carts.service.ts @@ -0,0 +1,73 @@ +import { Observable } from 'rxjs'; + +import { Cart, Carts } from './carts.model'; +import { + AddCartItemBody, + AddShippingMethodBody, + AddShippingMethodParams, + ApplyPromotionBody, + ApplyPromotionParams, + CreateCartBody, + DeleteCartParams, + GetCartListQuery, + GetCartParams, + PrepareCheckoutParams, + RemoveCartItemParams, + RemovePromotionParams, + UpdateCartAddressesBody, + UpdateCartAddressesParams, + UpdateCartBody, + UpdateCartItemBody, + UpdateCartItemParams, + UpdateCartParams, +} from './carts.request'; + +export abstract class CartService { + protected constructor(..._services: unknown[]) {} + + abstract getCart(params: GetCartParams, authorization?: string): Observable; + + abstract getCartList(query: GetCartListQuery, authorization?: string): Observable; + + abstract createCart(data: CreateCartBody, authorization?: string): Observable; + + abstract updateCart(params: UpdateCartParams, data: UpdateCartBody, authorization?: string): Observable; + + abstract deleteCart(params: DeleteCartParams, authorization?: string): Observable; + + abstract addCartItem(data: AddCartItemBody, authorization?: string): Observable; + + abstract updateCartItem( + params: UpdateCartItemParams, + data: UpdateCartItemBody, + authorization?: string, + ): Observable; + + abstract removeCartItem(params: RemoveCartItemParams, authorization?: string): Observable; + + abstract applyPromotion( + params: ApplyPromotionParams, + data: ApplyPromotionBody, + authorization?: string, + ): Observable; + + abstract removePromotion(params: RemovePromotionParams, authorization?: string): Observable; + + abstract getCurrentCart(authorization?: string): Observable; + + abstract prepareCheckout(params: PrepareCheckoutParams, authorization?: string): Observable; + + // Update cart addresses (shipping and/or billing) + abstract updateCartAddresses( + params: UpdateCartAddressesParams, + data: UpdateCartAddressesBody, + authorization?: string, + ): Observable; + + // Add shipping method to cart + abstract addShippingMethod( + params: AddShippingMethodParams, + data: AddShippingMethodBody, + authorization?: string, + ): Observable; +} diff --git a/packages/framework/src/modules/carts/index.ts b/packages/framework/src/modules/carts/index.ts new file mode 100644 index 000000000..aaec56a68 --- /dev/null +++ b/packages/framework/src/modules/carts/index.ts @@ -0,0 +1,5 @@ +export * as Model from './carts.model'; +export * as Request from './carts.request'; +export { CartService as Service } from './carts.service'; +export { CartsController as Controller } from './carts.controller'; +export { CartsModule as Module } from './carts.module'; diff --git a/packages/framework/src/modules/checkout/checkout.controller.ts b/packages/framework/src/modules/checkout/checkout.controller.ts new file mode 100644 index 000000000..0e8a9eb23 --- /dev/null +++ b/packages/framework/src/modules/checkout/checkout.controller.ts @@ -0,0 +1,74 @@ +import { Body, Controller, Get, Headers, Param, Post, UseInterceptors } from '@nestjs/common'; + +import { LoggerService } from '@o2s/utils.logger'; + +import { Request } from './'; +import { CheckoutService } from './checkout.service'; +import { AppHeaders } from '@/utils/models/headers'; + +@Controller('/checkout') +@UseInterceptors(LoggerService) +export class CheckoutController { + constructor(protected readonly checkoutService: CheckoutService) {} + + @Post(':cartId/addresses') + setAddresses( + @Param() params: Request.SetAddressesParams, + @Body() body: Request.SetAddressesBody, + @Headers() headers: AppHeaders, + ) { + return this.checkoutService.setAddresses(params, body, headers.authorization); + } + + @Post(':cartId/shipping-method') + setShippingMethod( + @Param() params: Request.SetShippingMethodParams, + @Body() body: Request.SetShippingMethodBody, + @Headers() headers: AppHeaders, + ) { + return this.checkoutService.setShippingMethod(params, body, headers.authorization); + } + + @Post(':cartId/payment') + setPayment( + @Param() params: Request.SetPaymentParams, + @Body() body: Request.SetPaymentBody, + @Headers() headers: AppHeaders, + ) { + return this.checkoutService.setPayment(params, body, headers.authorization); + } + + @Get(':cartId/shipping-options') + getShippingOptions(@Param() params: Request.GetShippingOptionsParams, @Headers() headers: AppHeaders) { + return this.checkoutService.getShippingOptions( + { ...params, locale: headers['x-locale'] }, + headers.authorization, + ); + } + + @Get(':cartId/summary') + getCheckoutSummary(@Param() params: Request.GetCheckoutSummaryParams, @Headers() headers: AppHeaders) { + return this.checkoutService.getCheckoutSummary( + { ...params, locale: headers['x-locale'] }, + headers.authorization, + ); + } + + @Post(':cartId/place-order') + placeOrder( + @Param() params: Request.PlaceOrderParams, + @Body() body: Request.PlaceOrderBody, + @Headers() headers: AppHeaders, + ) { + return this.checkoutService.placeOrder(params, body, headers.authorization); + } + + @Post(':cartId/complete') + completeCheckout( + @Param() params: Request.CompleteCheckoutParams, + @Body() body: Request.CompleteCheckoutBody, + @Headers() headers: AppHeaders, + ) { + return this.checkoutService.completeCheckout(params, body, headers.authorization); + } +} diff --git a/packages/framework/src/modules/checkout/checkout.model.ts b/packages/framework/src/modules/checkout/checkout.model.ts new file mode 100644 index 000000000..ecf1056d3 --- /dev/null +++ b/packages/framework/src/modules/checkout/checkout.model.ts @@ -0,0 +1,31 @@ +import * as Carts from '../carts'; +import * as Orders from '../orders'; + +import { Address, Price } from '@/utils/models'; + +export class CheckoutSummary { + cart!: Carts.Model.Cart; + shippingAddress!: Address.Address; + billingAddress!: Address.Address; + shippingMethod!: Orders.Model.ShippingMethod; + paymentMethod!: Carts.Model.PaymentMethod; + totals!: { + subtotal: Price.Price; + shipping: Price.Price; + tax: Price.Price; + discount: Price.Price; + total: Price.Price; + }; + notes?: string; + email?: string; // For guest checkout +} + +export class ShippingOptions { + data!: Orders.Model.ShippingMethod[]; + total!: number; +} + +export class PlaceOrderResponse { + order!: Orders.Model.Order; + paymentRedirectUrl?: string; // For redirect-based payment providers +} diff --git a/packages/framework/src/modules/checkout/checkout.module.ts b/packages/framework/src/modules/checkout/checkout.module.ts new file mode 100644 index 000000000..ea7a09f41 --- /dev/null +++ b/packages/framework/src/modules/checkout/checkout.module.ts @@ -0,0 +1,29 @@ +import { HttpModule } from '@nestjs/axios'; +import { DynamicModule, Global, Module, Type } from '@nestjs/common'; + +import { CheckoutController } from './checkout.controller'; +import { CheckoutService } from './checkout.service'; +import { ApiConfig } from '@/api-config'; + +@Global() +@Module({}) +export class CheckoutModule { + static register(config: ApiConfig): DynamicModule { + const service = config.integrations.checkout.service; + const controller = config.integrations.checkout.controller || CheckoutController; + const imports = config.integrations.checkout.imports || []; + + const provider = { + provide: CheckoutService, + useClass: service as Type, + }; + + return { + module: CheckoutModule, + providers: [provider], + imports: [HttpModule, ...imports], + controllers: [controller], + exports: [provider], + }; + } +} diff --git a/packages/framework/src/modules/checkout/checkout.request.ts b/packages/framework/src/modules/checkout/checkout.request.ts new file mode 100644 index 000000000..3f19fad44 --- /dev/null +++ b/packages/framework/src/modules/checkout/checkout.request.ts @@ -0,0 +1,71 @@ +import { Address } from '@/utils/models'; + +export class SetAddressesParams { + cartId!: string; +} + +export class SetAddressesBody { + shippingAddressId?: string; // Use saved address (authenticated users only) + shippingAddress?: Address.Address; // Or provide new address + billingAddressId?: string; // Use saved address (authenticated users only) + billingAddress?: Address.Address; // Or provide new address + notes?: string; + email?: string; // For guest checkout +} + +export class SetShippingMethodParams { + cartId!: string; +} + +export class SetShippingMethodBody { + shippingOptionId!: string; // Shipping option ID from getShippingOptions() +} + +export class SetPaymentParams { + cartId!: string; +} + +export class SetPaymentBody { + providerId!: string; + returnUrl!: string; + cancelUrl?: string; + metadata?: Record; +} + +export class GetShippingOptionsParams { + cartId!: string; + locale?: string; // From x-locale header +} + +export class GetCheckoutSummaryParams { + cartId!: string; + locale?: string; // From x-locale header +} + +export class PlaceOrderParams { + cartId!: string; +} + +export class PlaceOrderBody { + // Optional - can be empty if all data already in cart + // Allows frontend to confirm before placing + email?: string; // Required for guest checkout if not provided in shipping setup +} + +export class CompleteCheckoutParams { + cartId!: string; +} + +export class CompleteCheckoutBody { + shippingAddressId?: string; // Use saved address (authenticated users only) + shippingAddress?: Address.Address; // Or provide new address (required for guests) + billingAddressId?: string; // Use saved address (authenticated users only) + billingAddress?: Address.Address; // Or provide new address (can differ from shipping, required for guests) + shippingMethodId?: string; + paymentProviderId!: string; + returnUrl!: string; + cancelUrl?: string; + notes?: string; + email?: string; // Required for guest checkout (for order confirmation) + metadata?: Record; +} diff --git a/packages/framework/src/modules/checkout/checkout.service.ts b/packages/framework/src/modules/checkout/checkout.service.ts new file mode 100644 index 000000000..cdb6e3800 --- /dev/null +++ b/packages/framework/src/modules/checkout/checkout.service.ts @@ -0,0 +1,57 @@ +import { Observable } from 'rxjs'; + +import * as Carts from '../carts'; +import * as Payments from '../payments'; + +import * as Checkout from './'; + +export abstract class CheckoutService { + protected constructor(..._services: unknown[]) {} + + // Set addresses (shipping and/or billing) + abstract setAddresses( + params: Checkout.Request.SetAddressesParams, + data: Checkout.Request.SetAddressesBody, + authorization?: string, + ): Observable; + + // Set shipping method + abstract setShippingMethod( + params: Checkout.Request.SetShippingMethodParams, + data: Checkout.Request.SetShippingMethodBody, + authorization?: string, + ): Observable; + + // Set payment (independent action, can be called before or after shipping) + abstract setPayment( + params: Checkout.Request.SetPaymentParams, + data: Checkout.Request.SetPaymentBody, + authorization?: string, + ): Observable; + + // Get checkout summary (returns current state of cart with all checkout data) + abstract getCheckoutSummary( + params: Checkout.Request.GetCheckoutSummaryParams, + authorization?: string, + ): Observable; + + // Place order (validates required data is present, creates order) + abstract placeOrder( + params: Checkout.Request.PlaceOrderParams, + data?: Checkout.Request.PlaceOrderBody, + authorization?: string, + ): Observable; + + // Get available shipping options for a cart (params.locale for localized names/descriptions) + abstract getShippingOptions( + params: Checkout.Request.GetShippingOptionsParams, + authorization?: string, + ): Observable; + + // Complete checkout (orchestrates shipping + payment + order placement in single call) + abstract completeCheckout( + params: Checkout.Request.CompleteCheckoutParams, + data: Checkout.Request.CompleteCheckoutBody, + authorization?: string, + ): Observable; +} diff --git a/packages/framework/src/modules/checkout/index.ts b/packages/framework/src/modules/checkout/index.ts new file mode 100644 index 000000000..e1b22e0bb --- /dev/null +++ b/packages/framework/src/modules/checkout/index.ts @@ -0,0 +1,5 @@ +export * as Model from './checkout.model'; +export * as Request from './checkout.request'; +export { CheckoutService as Service } from './checkout.service'; +export { CheckoutController as Controller } from './checkout.controller'; +export { CheckoutModule as Module } from './checkout.module'; diff --git a/packages/framework/src/modules/customers/customers.controller.ts b/packages/framework/src/modules/customers/customers.controller.ts new file mode 100644 index 000000000..a324045c5 --- /dev/null +++ b/packages/framework/src/modules/customers/customers.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, UseInterceptors } from '@nestjs/common'; + +import { LoggerService } from '@o2s/utils.logger'; + +import { Request } from './'; +import { CustomerService } from './customers.service'; +import { AppHeaders } from '@/utils/models/headers'; + +@Controller('/customers/addresses') +@UseInterceptors(LoggerService) +export class CustomersController { + constructor(protected readonly customerService: CustomerService) {} + + @Get() + getAddresses(@Headers() headers: AppHeaders) { + return this.customerService.getAddresses(headers.authorization); + } + + @Get(':id') + getAddress(@Param() params: Request.GetAddressParams, @Headers() headers: AppHeaders) { + return this.customerService.getAddress(params, headers.authorization); + } + + @Post() + createAddress(@Body() body: Request.CreateAddressBody, @Headers() headers: AppHeaders) { + return this.customerService.createAddress(body, headers.authorization); + } + + @Patch(':id') + updateAddress( + @Param() params: Request.UpdateAddressParams, + @Body() body: Request.UpdateAddressBody, + @Headers() headers: AppHeaders, + ) { + return this.customerService.updateAddress(params, body, headers.authorization); + } + + @Delete(':id') + deleteAddress(@Param() params: Request.DeleteAddressParams, @Headers() headers: AppHeaders) { + return this.customerService.deleteAddress(params, headers.authorization); + } + + @Post(':id/default') + setDefaultAddress(@Param() params: Request.SetDefaultAddressParams, @Headers() headers: AppHeaders) { + return this.customerService.setDefaultAddress(params, headers.authorization); + } +} diff --git a/packages/framework/src/modules/customers/customers.model.ts b/packages/framework/src/modules/customers/customers.model.ts new file mode 100644 index 000000000..335392583 --- /dev/null +++ b/packages/framework/src/modules/customers/customers.model.ts @@ -0,0 +1,13 @@ +import { Address, Pagination } from '@/utils/models'; + +export class CustomerAddress { + id!: string; + customerId!: string; + label?: string; // e.g., "Home", "Work", "Billing" + isDefault?: boolean; + address!: Address.Address; + createdAt!: string; + updatedAt!: string; +} + +export type CustomerAddresses = Pagination.Paginated; diff --git a/packages/framework/src/modules/customers/customers.module.ts b/packages/framework/src/modules/customers/customers.module.ts new file mode 100644 index 000000000..31eecf31b --- /dev/null +++ b/packages/framework/src/modules/customers/customers.module.ts @@ -0,0 +1,29 @@ +import { HttpModule } from '@nestjs/axios'; +import { DynamicModule, Global, Module, Type } from '@nestjs/common'; + +import { CustomersController } from './customers.controller'; +import { CustomerService } from './customers.service'; +import { ApiConfig } from '@/api-config'; + +@Global() +@Module({}) +export class CustomersModule { + static register(config: ApiConfig): DynamicModule { + const service = config.integrations.customers.service; + const controller = config.integrations.customers.controller || CustomersController; + const imports = config.integrations.customers.imports || []; + + const provider = { + provide: CustomerService, + useClass: service as Type, + }; + + return { + module: CustomersModule, + providers: [provider], + imports: [HttpModule, ...imports], + controllers: [controller], + exports: [provider], + }; + } +} diff --git a/packages/framework/src/modules/customers/customers.request.ts b/packages/framework/src/modules/customers/customers.request.ts new file mode 100644 index 000000000..213776b1b --- /dev/null +++ b/packages/framework/src/modules/customers/customers.request.ts @@ -0,0 +1,29 @@ +import { Address } from '@/utils/models'; + +export class GetAddressParams { + id!: string; +} + +export class CreateAddressBody { + label?: string; + isDefault?: boolean; + address!: Address.Address; +} + +export class UpdateAddressParams { + id!: string; +} + +export class UpdateAddressBody { + label?: string; + isDefault?: boolean; + address?: Address.Address; +} + +export class DeleteAddressParams { + id!: string; +} + +export class SetDefaultAddressParams { + id!: string; +} diff --git a/packages/framework/src/modules/customers/customers.service.ts b/packages/framework/src/modules/customers/customers.service.ts new file mode 100644 index 000000000..ea6192a16 --- /dev/null +++ b/packages/framework/src/modules/customers/customers.service.ts @@ -0,0 +1,32 @@ +import { Observable } from 'rxjs'; + +import * as Customers from './'; + +export abstract class CustomerService { + protected constructor(..._services: unknown[]) {} + + abstract getAddresses(authorization?: string): Observable; + + abstract getAddress( + params: Customers.Request.GetAddressParams, + authorization?: string, + ): Observable; + + abstract createAddress( + data: Customers.Request.CreateAddressBody, + authorization?: string, + ): Observable; + + abstract updateAddress( + params: Customers.Request.UpdateAddressParams, + data: Customers.Request.UpdateAddressBody, + authorization?: string, + ): Observable; + + abstract deleteAddress(params: Customers.Request.DeleteAddressParams, authorization?: string): Observable; + + abstract setDefaultAddress( + params: Customers.Request.SetDefaultAddressParams, + authorization?: string, + ): Observable; +} diff --git a/packages/framework/src/modules/customers/index.ts b/packages/framework/src/modules/customers/index.ts new file mode 100644 index 000000000..9e2b124ab --- /dev/null +++ b/packages/framework/src/modules/customers/index.ts @@ -0,0 +1,5 @@ +export * as Model from './customers.model'; +export * as Request from './customers.request'; +export { CustomerService as Service } from './customers.service'; +export { CustomersController as Controller } from './customers.controller'; +export { CustomersModule as Module } from './customers.module'; diff --git a/packages/framework/src/modules/orders/orders.model.ts b/packages/framework/src/modules/orders/orders.model.ts index 70ef2a40e..44b6b046d 100644 --- a/packages/framework/src/modules/orders/orders.model.ts +++ b/packages/framework/src/modules/orders/orders.model.ts @@ -46,6 +46,7 @@ export class Order { shippingMethods!: ShippingMethod[]; customerComment?: string; documents?: Document[]; + email?: string; // For guest orders (order confirmation) } export class OrderItem { diff --git a/packages/framework/src/modules/payments/index.ts b/packages/framework/src/modules/payments/index.ts new file mode 100644 index 000000000..f665a69e2 --- /dev/null +++ b/packages/framework/src/modules/payments/index.ts @@ -0,0 +1,5 @@ +export * as Model from './payments.model'; +export * as Request from './payments.request'; +export { PaymentService as Service } from './payments.service'; +export { PaymentsController as Controller } from './payments.controller'; +export { PaymentsModule as Module } from './payments.module'; diff --git a/packages/framework/src/modules/payments/payments.controller.ts b/packages/framework/src/modules/payments/payments.controller.ts new file mode 100644 index 000000000..1d3102520 --- /dev/null +++ b/packages/framework/src/modules/payments/payments.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query, UseInterceptors } from '@nestjs/common'; + +import { LoggerService } from '@o2s/utils.logger'; + +import { Request } from './'; +import { PaymentService } from './payments.service'; +import { AppHeaders } from '@/utils/models/headers'; + +@Controller('/payments') +@UseInterceptors(LoggerService) +export class PaymentsController { + constructor(protected readonly paymentService: PaymentService) {} + + @Get('providers') + getProviders(@Query() params: Request.GetProvidersParams, @Headers() headers: AppHeaders) { + return this.paymentService.getProviders({ ...params, locale: headers['x-locale'] }, headers.authorization); + } + + @Post('sessions') + createSession(@Body() body: Request.CreateSessionBody, @Headers() headers: AppHeaders) { + return this.paymentService.createSession(body, headers.authorization); + } + + @Get('sessions/:id') + getSession(@Param() params: Request.GetSessionParams, @Headers() headers: AppHeaders) { + return this.paymentService.getSession(params, headers.authorization); + } + + @Patch('sessions/:id') + updateSession( + @Param() params: Request.UpdateSessionParams, + @Body() body: Request.UpdateSessionBody, + @Headers() headers: AppHeaders, + ) { + return this.paymentService.updateSession(params, body, headers.authorization); + } + + @Delete('sessions/:id') + cancelSession(@Param() params: Request.CancelSessionParams, @Headers() headers: AppHeaders) { + return this.paymentService.cancelSession(params, headers.authorization); + } +} diff --git a/packages/framework/src/modules/payments/payments.model.ts b/packages/framework/src/modules/payments/payments.model.ts new file mode 100644 index 000000000..19c824358 --- /dev/null +++ b/packages/framework/src/modules/payments/payments.model.ts @@ -0,0 +1,26 @@ +import { Pagination } from '@/utils/models'; + +export class PaymentProvider { + id!: string; + name!: string; + type!: string; + isEnabled!: boolean; + requiresRedirect!: boolean; // true for redirect-based providers (Stripe Checkout) + config?: Record; // Provider-specific config +} + +export type PaymentSessionStatus = 'PENDING' | 'AUTHORIZED' | 'CAPTURED' | 'FAILED' | 'CANCELLED'; + +export class PaymentSession { + id!: string; + cartId!: string; + providerId!: string; + status!: PaymentSessionStatus; + redirectUrl?: string; // For redirect-based providers + clientSecret?: string; // For embedded payment forms + expiresAt?: string; + metadata?: Record; +} + +export type PaymentProviders = Pagination.Paginated; +export type PaymentSessions = Pagination.Paginated; diff --git a/packages/framework/src/modules/payments/payments.module.ts b/packages/framework/src/modules/payments/payments.module.ts new file mode 100644 index 000000000..417ab6cb3 --- /dev/null +++ b/packages/framework/src/modules/payments/payments.module.ts @@ -0,0 +1,29 @@ +import { HttpModule } from '@nestjs/axios'; +import { DynamicModule, Global, Module, Type } from '@nestjs/common'; + +import { PaymentsController } from './payments.controller'; +import { PaymentService } from './payments.service'; +import { ApiConfig } from '@/api-config'; + +@Global() +@Module({}) +export class PaymentsModule { + static register(config: ApiConfig): DynamicModule { + const service = config.integrations.payments.service; + const controller = config.integrations.payments.controller || PaymentsController; + const imports = config.integrations.payments.imports || []; + + const provider = { + provide: PaymentService, + useClass: service as Type, + }; + + return { + module: PaymentsModule, + providers: [provider], + imports: [HttpModule, ...imports], + controllers: [controller], + exports: [provider], + }; + } +} diff --git a/packages/framework/src/modules/payments/payments.request.ts b/packages/framework/src/modules/payments/payments.request.ts new file mode 100644 index 000000000..268fdd32c --- /dev/null +++ b/packages/framework/src/modules/payments/payments.request.ts @@ -0,0 +1,30 @@ +export class GetProvidersParams { + regionId!: string; + locale?: string; // From x-locale header +} + +export class CreateSessionBody { + cartId!: string; + providerId!: string; + returnUrl!: string; // Where to redirect after payment + cancelUrl?: string; // Where to redirect if payment cancelled + metadata?: Record; +} + +export class GetSessionParams { + id!: string; +} + +export class UpdateSessionParams { + id!: string; +} + +export class UpdateSessionBody { + returnUrl?: string; + cancelUrl?: string; + metadata?: Record; +} + +export class CancelSessionParams { + id!: string; +} diff --git a/packages/framework/src/modules/payments/payments.service.ts b/packages/framework/src/modules/payments/payments.service.ts new file mode 100644 index 000000000..3b0c1da06 --- /dev/null +++ b/packages/framework/src/modules/payments/payments.service.ts @@ -0,0 +1,30 @@ +import { Observable } from 'rxjs'; + +import * as Payments from './'; + +export abstract class PaymentService { + protected constructor(..._services: unknown[]) {} + + abstract getProviders( + params: Payments.Request.GetProvidersParams, + authorization?: string, + ): Observable; + + abstract createSession( + data: Payments.Request.CreateSessionBody, + authorization?: string, + ): Observable; + + abstract getSession( + params: Payments.Request.GetSessionParams, + authorization?: string, + ): Observable; + + abstract updateSession( + params: Payments.Request.UpdateSessionParams, + data: Payments.Request.UpdateSessionBody, + authorization?: string, + ): Observable; + + abstract cancelSession(params: Payments.Request.CancelSessionParams, authorization?: string): Observable; +} diff --git a/packages/framework/src/modules/users/users.controller.ts b/packages/framework/src/modules/users/users.controller.ts index 3c18badfd..6d9b4d9c2 100644 --- a/packages/framework/src/modules/users/users.controller.ts +++ b/packages/framework/src/modules/users/users.controller.ts @@ -2,8 +2,6 @@ import { Body, Controller, Delete, Get, Headers, Param, Patch, UseInterceptors } import { LoggerService } from '@o2s/utils.logger'; -import * as Auth from '@/modules/auth'; - import { Request } from './'; import { UserService } from './users.service'; import { AppHeaders } from '@/utils/models/headers'; @@ -14,25 +12,21 @@ export class UserController { constructor(protected readonly userService: UserService) {} @Get('/me') - @Auth.Decorators.Roles({ roles: [] }) getCurrentUser(@Headers() headers: AppHeaders) { return this.userService.getCurrentUser(headers.authorization); } @Get(':id') - @Auth.Decorators.Roles({ roles: [] }) getUser(@Param() params: Request.GetUserParams, @Headers() headers: AppHeaders) { return this.userService.getUser(params, headers.authorization); } @Patch('/me') - @Auth.Decorators.Roles({ roles: [] }) updateCurrentUser(@Body() body: Request.PostUserBody, @Headers() headers: AppHeaders) { return this.userService.updateCurrentUser(body, headers.authorization); } @Patch(':id') - @Auth.Decorators.Roles({ roles: [] }) updateUser( @Param() params: Request.GetUserParams, @Body() body: Request.PostUserBody, @@ -42,25 +36,21 @@ export class UserController { } @Get('/me/customers') - @Auth.Decorators.Roles({ roles: [] }) getCustomersForCurrentUser(@Headers() headers: AppHeaders) { return this.userService.getCurrentUserCustomers(headers.authorization); } @Get('/me/customers/:id') - @Auth.Decorators.Roles({ roles: [] }) getCustomerForCurrentUserById(@Param() params: Request.GetCustomerParams, @Headers() headers: AppHeaders) { return this.userService.getCurrentUserCustomer(params, headers.authorization); } @Delete('/me') - @Auth.Decorators.Roles({ roles: [] }) deleteCurrentUser(@Headers() headers: AppHeaders) { return this.userService.deleteCurrentUser(headers.authorization); } @Delete(':id') - @Auth.Decorators.Roles({ roles: [] }) deleteUser(@Param() params: Request.GetUserParams, @Headers() headers: AppHeaders) { return this.userService.deleteUser(params, headers.authorization); } diff --git a/packages/framework/src/utils/models/address.ts b/packages/framework/src/utils/models/address.ts index 737ca535c..26f144ac3 100644 --- a/packages/framework/src/utils/models/address.ts +++ b/packages/framework/src/utils/models/address.ts @@ -1,4 +1,6 @@ export class Address { + firstName?: string; + lastName?: string; country!: string; district?: string; region?: string; diff --git a/packages/integrations/medusajs/src/integration.ts b/packages/integrations/medusajs/src/integration.ts index f9266939e..1cd379f99 100644 --- a/packages/integrations/medusajs/src/integration.ts +++ b/packages/integrations/medusajs/src/integration.ts @@ -1,9 +1,13 @@ import { ApiConfig } from '@o2s/framework/modules'; -import { Auth } from '@o2s/framework/modules'; +import { Auth, Customers } from '@o2s/framework/modules'; import { MedusaJsModule } from '@/modules/medusajs/medusajs.module'; +import { Service as CartsService } from './modules/carts'; +import { Service as CheckoutService } from './modules/checkout'; +import { Service as CustomersService } from './modules/customers'; import { Service as OrdersService } from './modules/orders'; +import { Service as PaymentsService } from './modules/payments'; import { Service as ProductsService } from './modules/products'; import { Service as ResourcesService } from './modules/resources'; @@ -25,4 +29,24 @@ export const Config: Partial = { service: ProductsService, imports: [MedusaJsModule, Auth.Module], }, + carts: { + name: 'medusajs', + service: CartsService, + imports: [MedusaJsModule, Auth.Module, Customers.Module], + }, + customers: { + name: 'medusajs', + service: CustomersService, + imports: [MedusaJsModule, Auth.Module], + }, + payments: { + name: 'medusajs', + service: PaymentsService, + imports: [MedusaJsModule, Auth.Module], + }, + checkout: { + name: 'medusajs', + service: CheckoutService, + imports: [MedusaJsModule, Auth.Module], + }, }; diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts new file mode 100644 index 000000000..1e1fa3cc1 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts @@ -0,0 +1,182 @@ +import { HttpTypes } from '@medusajs/types'; +import { describe, expect, it } from 'vitest'; + +import { mapCart, mapCarts } from './carts.mapper'; + +const defaultCurrency = 'EUR'; + +function minimalCart(overrides: Record = {}): HttpTypes.StoreCart { + return { + id: 'cart_1', + currency_code: 'eur', + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-02'), + items: [], + subtotal: 9000, + total: 10000, + discount_total: 0, + tax_total: 1000, + shipping_total: 0, + shipping_address: null, + billing_address: null, + shipping_methods: [], + metadata: {}, + ...overrides, + } as unknown as HttpTypes.StoreCart; +} + +describe('carts.mapper', () => { + describe('mapCart', () => { + it('should map id, customerId, currency, items, subtotal, total', () => { + const cart = minimalCart(); + const result = mapCart(cart, defaultCurrency); + expect(result.id).toBe('cart_1'); + expect(result.customerId).toBeUndefined(); + expect(result.currency).toBe('EUR'); + expect(result.items.data).toHaveLength(0); + expect(result.items.total).toBe(0); + expect(result.subtotal?.value).toBe(9000); + expect(result.total?.value).toBe(10000); + expect(result.createdAt).toBe(cart.created_at?.toString()); + expect(result.updatedAt).toBe(cart.updated_at?.toString()); + }); + + it('should map customerId when present', () => { + const cart = minimalCart({ customer_id: 'cust_1' }); + const result = mapCart(cart, defaultCurrency); + expect(result.customerId).toBe('cust_1'); + }); + + it('should throw when currency_code is missing', () => { + const cart = minimalCart({ currency_code: undefined }); + expect(() => mapCart(cart, defaultCurrency)).toThrow('Cart cart_1 has no currency code'); + }); + + it('should map shipping and billing address', () => { + const cart = minimalCart({ + shipping_address: { + first_name: 'John', + last_name: 'Doe', + country_code: 'PL', + province: 'Mazovia', + address_1: 'Street', + address_2: '1', + city: 'Warsaw', + postal_code: '00-001', + phone: '+48', + }, + billing_address: { + first_name: 'Jane', + last_name: 'Doe', + country_code: 'PL', + province: '', + address_1: 'Billing St', + address_2: '', + city: 'Warsaw', + postal_code: '00-002', + phone: '', + }, + }); + const result = mapCart(cart, defaultCurrency); + expect(result.shippingAddress).toBeDefined(); + expect(result.shippingAddress?.country).toBe('PL'); + expect(result.shippingAddress?.city).toBe('Warsaw'); + expect(result.shippingAddress?.streetName).toBe('Street'); + expect(result.billingAddress).toBeDefined(); + expect(result.billingAddress?.country).toBe('PL'); + expect(result.billingAddress?.city).toBe('Warsaw'); + expect(result.billingAddress?.streetName).toBe('Billing St'); + }); + + it('should map line items with sku from variant_sku, quantity, unit_price', () => { + const cart = minimalCart({ + items: [ + { + id: 'item_1', + product_id: 'prod_1', + variant_id: 'var_1', + quantity: 2, + unit_price: 500, + subtotal: 1000, + total: 1000, + discount_total: 0, + product_title: 'Product', + title: 'Product', + variant_sku: 'SKU1', + product_description: '', + product_subtitle: '', + thumbnail: null, + }, + ] as unknown as HttpTypes.StoreCartLineItem[], + }); + const result = mapCart(cart, defaultCurrency); + expect(result.items.data).toHaveLength(1); + expect(result.items.data[0]?.sku).toBe('SKU1'); + expect(result.items.data[0]?.quantity).toBe(2); + expect(result.items.data[0]?.price?.value).toBe(500); + }); + + it('should map shipping_methods[0] when present', () => { + const cart = minimalCart({ + shipping_methods: [ + { + id: 'sm_1', + name: 'Express', + description: 'Fast delivery', + total: 500, + subtotal: 500, + }, + ] as HttpTypes.StoreCartShippingMethod[], + }); + const result = mapCart(cart, defaultCurrency); + expect(result.shippingMethod).toBeDefined(); + expect(result.shippingMethod?.id).toBe('sm_1'); + expect(result.shippingMethod?.name).toBe('Express'); + }); + + it('should map metadata.paymentMethod when present', () => { + const cart = minimalCart({ + metadata: { + paymentMethod: { + id: 'pm_1', + name: 'Credit Card', + description: 'Pay with card', + }, + }, + }); + const result = mapCart(cart, defaultCurrency); + expect(result.paymentMethod).toBeDefined(); + expect(result.paymentMethod?.id).toBe('pm_1'); + expect(result.paymentMethod?.name).toBe('Credit Card'); + expect(result.paymentMethod?.description).toBe('Pay with card'); + }); + + it('should map promotions when present', () => { + const cart = minimalCart({ + promotions: [{ id: 'promo_1', code: 'SAVE10' }] as HttpTypes.StoreCart['promotions'], + }); + const result = mapCart(cart, defaultCurrency); + expect(result.promotions).toHaveLength(1); + expect(result.promotions?.[0]?.id).toBe('promo_1'); + expect(result.promotions?.[0]?.code).toBe('SAVE10'); + }); + }); + + describe('mapCarts', () => { + it('should map data and total from count', () => { + const response = { carts: [minimalCart(), minimalCart({ id: 'cart_2' })], count: 2 }; + const result = mapCarts(response, defaultCurrency); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + expect(result.data[0]?.id).toBe('cart_1'); + expect(result.data[1]?.id).toBe('cart_2'); + }); + + it('should map data and total from carts.length when count missing', () => { + const response = { carts: [minimalCart(), minimalCart({ id: 'cart_2' })] }; + const result = mapCarts(response, defaultCurrency); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts new file mode 100644 index 000000000..d54191983 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -0,0 +1,153 @@ +import { HttpTypes } from '@medusajs/types'; +import { BadRequestException } from '@nestjs/common'; + +import { Carts, Models, Orders, Products } from '@o2s/framework/modules'; + +import { mapAddress } from '@/utils/address'; +import { parseCurrency } from '@/utils/currency'; +import { asRecord } from '@/utils/metadata'; +import { mapPrice } from '@/utils/price'; + +export const mapCarts = ( + carts: { carts: HttpTypes.StoreCart[]; count?: number }, + defaultCurrency: string, +): Carts.Model.Carts => { + return { + data: carts.carts.map((cart: HttpTypes.StoreCart) => mapCart(cart, defaultCurrency)), + total: carts.count ?? carts.carts.length, + }; +}; + +export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Carts.Model.Cart => { + if (!cart.currency_code) { + throw new BadRequestException(`Cart ${cart.id} has no currency code`); + } + const currency = parseCurrency(cart.currency_code); + + return { + id: cart.id, + customerId: cart.customer_id ?? undefined, + name: undefined, // Medusa doesn't have cart names by default + createdAt: cart.created_at?.toString() ?? new Date().toISOString(), + updatedAt: cart.updated_at?.toString() ?? new Date().toISOString(), + expiresAt: undefined, // Medusa handles expiration differently + regionId: cart.region_id ?? undefined, + currency, + items: { + data: cart.items?.map((item) => mapCartItem(item, currency)) ?? [], + total: cart.items?.length ?? 0, + }, + subtotal: mapPrice(cart.subtotal, currency, `Cart ${cart.id} subtotal`), + discountTotal: mapPrice(cart.discount_total, currency, `Cart ${cart.id} discountTotal`), + taxTotal: mapPrice(cart.tax_total, currency, `Cart ${cart.id} taxTotal`), + shippingTotal: mapPrice(cart.shipping_total, currency, `Cart ${cart.id} shippingTotal`), + total: mapPrice(cart.total, currency, `Cart ${cart.id} total`), + shippingAddress: mapAddress(cart.shipping_address), + billingAddress: mapAddress(cart.billing_address), + shippingMethod: cart.shipping_methods?.[0] ? mapShippingMethod(cart.shipping_methods[0], currency) : undefined, + paymentMethod: mapPaymentMethodFromMetadata(asRecord(cart.metadata)), + promotions: mapPromotions(cart), + metadata: asRecord(cart.metadata), + notes: undefined, + email: cart.email ?? undefined, + }; +}; + +const mapCartItem = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.Currency): Carts.Model.CartItem => { + return { + id: item.id, + sku: item.variant_sku ?? item.variant_id ?? '', + quantity: item.quantity, + price: mapPrice(item.unit_price, currency, `Cart item ${item.id} unit_price`), + subtotal: mapPrice(item.subtotal, currency, `Cart item ${item.id} subtotal`), + discountTotal: mapPrice(item.discount_total, currency, `Cart item ${item.id} discountTotal`), + total: mapPrice(item.total, currency, `Cart item ${item.id} total`), + unit: 'PCS', + currency, + product: mapProduct(item, currency), + metadata: asRecord(item.metadata), + }; +}; + +const mapProduct = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.Currency): Products.Model.Product => { + return { + id: item.product_id ?? '', + sku: item.variant_sku ?? '', + name: item.product_title ?? item.title ?? '', + description: item.product_description ?? '', + shortDescription: item.product_subtitle ?? '', + image: item.thumbnail + ? { + url: item.thumbnail, + alt: item.product_title ?? item.title ?? '', + } + : undefined, + price: mapPrice(item.unit_price, currency, `Cart product ${item.product_id} unit_price`), + link: '', + type: 'PHYSICAL', + category: '', + tags: [], + }; +}; + +const mapPaymentMethodFromMetadata = (metadata: Record): Carts.Model.PaymentMethod | undefined => { + const stored = metadata?.paymentMethod; + if (stored === null || stored === undefined || typeof stored !== 'object' || Array.isArray(stored)) + return undefined; + + const storedObj = stored as Record; + const id = storedObj.id; + const name = storedObj.name; + if (typeof id !== 'string' || typeof name !== 'string') return undefined; + + const description = storedObj.description; + + return { + id, + name, + description: typeof description === 'string' ? description : undefined, + }; +}; + +const mapShippingMethod = ( + method: HttpTypes.StoreCartShippingMethod, + currency: Models.Price.Currency, +): Orders.Model.ShippingMethod => { + return { + id: method.id, + name: method.name ?? '', + description: method.description ?? '', + total: mapPrice(method.total, currency, `Cart shipping method ${method.id} total`), + subtotal: mapPrice(method.subtotal, currency, `Cart shipping method ${method.id} subtotal`), + }; +}; + +const mapPromotions = (cart: HttpTypes.StoreCart): Carts.Model.Promotion[] | undefined => { + if (!cart.promotions || !Array.isArray(cart.promotions)) { + return undefined; + } + + const promotions: Carts.Model.Promotion[] = cart.promotions.map((promo) => { + const applicationMethod = promo.application_method; + let promotionType: Carts.Model.PromotionType | undefined; + if (applicationMethod?.type) { + if (applicationMethod.type === 'fixed') { + promotionType = 'FIXED_AMOUNT'; + } else if (applicationMethod.type === 'percentage') { + promotionType = 'PERCENTAGE'; + } else if (applicationMethod.type === 'free_shipping') { + promotionType = 'FREE_SHIPPING'; + } + } + + return { + id: promo.id || promo.code || '', + code: promo.code ?? '', + name: promo.code ?? undefined, + type: promotionType, + value: applicationMethod?.value != null ? String(applicationMethod.value) : undefined, + }; + }); + + return promotions.length > 0 ? promotions : undefined; +}; diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts new file mode 100644 index 000000000..52574eaf8 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -0,0 +1,545 @@ +import { HttpTypes } from '@medusajs/types'; +import { BadRequestException, NotFoundException, NotImplementedException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Auth, Carts } from '@o2s/framework/modules'; + +import { CartsService } from './carts.service'; + +const DEFAULT_CURRENCY = 'EUR'; + +const minimalCartItem = { + id: 'item_1', + product_id: 'prod_1', + variant_id: 'var_1', + variant_sku: 'SKU1', + quantity: 1, + unit_price: 1000, + subtotal: 1000, + total: 1000, + discount_total: 0, + product_title: 'Product', + title: 'Product', + product_description: '', + product_subtitle: '', + thumbnail: null, +}; + +const minimalCart = { + id: 'cart_1', + customer_id: null, + currency_code: 'eur', + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-02'), + items: [], + subtotal: 9000, + total: 10000, + discount_total: 0, + tax_total: 1000, + shipping_total: 0, + shipping_address: null, + billing_address: null, + shipping_methods: [], + metadata: {}, +}; + +describe('CartsService', () => { + let service: CartsService; + let mockSdk: { + store: { + cart: { + retrieve: ReturnType; + create: ReturnType; + createLineItem: ReturnType; + updateLineItem: ReturnType; + deleteLineItem: ReturnType; + update: ReturnType; + addShippingMethod: ReturnType; + }; + }; + client: { fetch: ReturnType }; + }; + let mockMedusaJsService: { getSdk: ReturnType; getStoreApiHeaders: ReturnType }; + let mockAuthService: { getCustomerId: ReturnType }; + let mockConfig: { get: ReturnType }; + let mockLogger: { debug: ReturnType }; + let mockCustomersService: Record>; + + beforeEach(() => { + vi.restoreAllMocks(); + mockSdk = { + store: { + cart: { + retrieve: vi.fn(), + create: vi.fn(), + createLineItem: vi.fn(), + updateLineItem: vi.fn(), + deleteLineItem: vi.fn(), + update: vi.fn(), + addShippingMethod: vi.fn(), + }, + }, + client: { fetch: vi.fn() }, + }; + mockMedusaJsService = { + getSdk: vi.fn(() => mockSdk), + getStoreApiHeaders: vi.fn(() => ({})), + }; + mockAuthService = { getCustomerId: vi.fn() }; + mockConfig = { + get: vi.fn((key: string) => (key === 'DEFAULT_CURRENCY' ? DEFAULT_CURRENCY : '')), + }; + mockLogger = { debug: vi.fn() }; + mockCustomersService = {}; + + service = new CartsService( + mockConfig as unknown as ConfigService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + mockAuthService as unknown as Auth.Service, + mockCustomersService as unknown as import('@o2s/framework/modules').Customers.Service, + ); + }); + + describe('constructor', () => { + it('should throw when DEFAULT_CURRENCY is not defined', () => { + vi.mocked(mockConfig.get).mockReturnValue(''); + + expect( + () => + new CartsService( + mockConfig as unknown as ConfigService, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + mockAuthService as unknown as Auth.Service, + mockCustomersService as unknown as import('@o2s/framework/modules').Customers.Service, + ), + ).toThrow('DEFAULT_CURRENCY is not defined'); + }); + }); + + describe('getCart', () => { + it('should call retrieve and return mapped cart', async () => { + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); + + const result = await firstValueFrom(service.getCart({ id: 'cart_1' }, 'Bearer token')); + + expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith( + 'cart_1', + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + expect(result).toBeDefined(); + expect(result?.id).toBe('cart_1'); + expect(result?.currency).toBe('EUR'); + }); + + it('should throw UnauthorizedException when cart.customerId !== auth customerId', async () => { + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: { ...minimalCart, customer_id: 'cust_1' } }); + mockAuthService.getCustomerId.mockReturnValue('cust_other'); + + await expect(firstValueFrom(service.getCart({ id: 'cart_1' }, 'Bearer token'))).rejects.toThrow( + UnauthorizedException, + ); + expect(mockAuthService.getCustomerId).toHaveBeenCalledWith('Bearer token'); + }); + + it('should throw NotFoundException on 404', async () => { + mockSdk.store.cart.retrieve.mockRejectedValue({ status: 404 }); + + await expect(firstValueFrom(service.getCart({ id: 'missing' }, 'Bearer token'))).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('createCart', () => { + it('should call cart.create with currency_code and region_id', async () => { + mockSdk.store.cart.create.mockResolvedValue({ cart: minimalCart }); + + await firstValueFrom(service.createCart({ currency: 'EUR', regionId: 'reg_1' }, 'Bearer token')); + + expect(mockSdk.store.cart.create).toHaveBeenCalledWith( + { currency_code: 'eur', region_id: 'reg_1', metadata: undefined }, + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + }); + }); + + describe('addCartItem', () => { + it('should throw BadRequestException when sku is missing', () => { + expect(() => service.addCartItem({ quantity: 1 } as Carts.Request.AddCartItemBody, 'Bearer token')).toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException when cartId absent and currency missing', async () => { + await expect( + firstValueFrom( + service.addCartItem({ sku: 'SKU1', quantity: 1 } as Carts.Request.AddCartItemBody, 'Bearer token'), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should retrieve then createLineItem when cartId provided', async () => { + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); + mockSdk.store.cart.createLineItem.mockResolvedValue({ + cart: { ...minimalCart, items: [minimalCartItem] }, + }); + mockAuthService.getCustomerId.mockReturnValue(undefined); + + const result = await firstValueFrom( + service.addCartItem( + { cartId: 'cart_1', sku: 'SKU1', quantity: 2 } as Carts.Request.AddCartItemBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith( + 'cart_1', + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + expect(mockSdk.store.cart.createLineItem).toHaveBeenCalledWith( + 'cart_1', + { variant_id: 'SKU1', quantity: 2, metadata: undefined }, + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + expect(result).toBeDefined(); + expect(result?.id).toBe('cart_1'); + }); + + it('should succeed when cart has no customerId (guest cart)', async () => { + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: { ...minimalCart, customer_id: null } }); + mockSdk.store.cart.createLineItem.mockResolvedValue({ cart: minimalCart }); + mockAuthService.getCustomerId.mockReturnValue(undefined); + + const result = await firstValueFrom( + service.addCartItem( + { cartId: 'cart_1', sku: 'SKU1', quantity: 1 } as Carts.Request.AddCartItemBody, + 'Bearer token', + ), + ); + + expect(result?.id).toBe('cart_1'); + }); + + it('should create new cart for guest when no cartId (createCartAndAddItem)', async () => { + mockSdk.store.cart.create.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_new' } }); + mockSdk.store.cart.createLineItem.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_new' } }); + mockAuthService.getCustomerId.mockReturnValue(undefined); + + const result = await firstValueFrom( + service.addCartItem( + { sku: 'SKU1', quantity: 2, currency: 'EUR' } as Carts.Request.AddCartItemBody, + undefined, + ), + ); + + expect(mockSdk.store.cart.create).toHaveBeenCalledWith( + { currency_code: 'eur', region_id: undefined, metadata: undefined }, + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + expect(mockSdk.store.cart.createLineItem).toHaveBeenCalled(); + expect(result?.id).toBe('cart_new'); + }); + + it('should create new cart for authenticated user when no cartId', async () => { + mockSdk.store.cart.create.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_auth' } }); + mockSdk.store.cart.createLineItem.mockResolvedValue({ cart: { ...minimalCart, id: 'cart_auth' } }); + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + + const result = await firstValueFrom( + service.addCartItem( + { sku: 'SKU1', quantity: 1, currency: 'EUR' } as Carts.Request.AddCartItemBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.cart.create).toHaveBeenCalled(); + expect(result?.id).toBe('cart_auth'); + }); + }); + + describe('updateCartItem', () => { + it('should call updateLineItem', async () => { + mockSdk.store.cart.updateLineItem.mockResolvedValue({ cart: minimalCart }); + + const result = await firstValueFrom( + service.updateCartItem({ cartId: 'cart_1', itemId: 'item_1' }, { quantity: 3 }, 'Bearer token'), + ); + + expect(mockSdk.store.cart.updateLineItem).toHaveBeenCalledWith( + 'cart_1', + 'item_1', + { quantity: 3, metadata: undefined }, + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + expect(result?.id).toBe('cart_1'); + }); + }); + + describe('removeCartItem', () => { + it('should call deleteLineItem', async () => { + mockSdk.store.cart.deleteLineItem.mockResolvedValue({ + parent: minimalCart, + } as unknown as HttpTypes.StoreLineItemDeleteResponse); + + const result = await firstValueFrom( + service.removeCartItem({ cartId: 'cart_1', itemId: 'item_1' }, 'Bearer token'), + ); + + expect(mockSdk.store.cart.deleteLineItem).toHaveBeenCalledWith( + 'cart_1', + 'item_1', + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + expect(result?.id).toBe('cart_1'); + }); + + it('should throw NotFoundException when parent is missing', async () => { + mockSdk.store.cart.deleteLineItem.mockResolvedValue({ + parent: null, + } as unknown as HttpTypes.StoreLineItemDeleteResponse); + + await expect( + firstValueFrom(service.removeCartItem({ cartId: 'cart_1', itemId: 'item_1' }, 'Bearer token')), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateCart', () => { + it('should call cart.update with regionId, email, metadata, notes', async () => { + mockSdk.store.cart.update.mockResolvedValue({ cart: minimalCart }); + + const result = await firstValueFrom( + service.updateCart( + { id: 'cart_1' }, + { + regionId: 'reg_1', + email: 'user@test.com', + notes: 'Gift wrap', + metadata: { custom: 'value' }, + } as Carts.Request.UpdateCartBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.cart.update).toHaveBeenCalledWith( + 'cart_1', + expect.objectContaining({ + region_id: 'reg_1', + email: 'user@test.com', + metadata: expect.objectContaining({ notes: 'Gift wrap', custom: 'value' }), + }), + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + expect(result?.id).toBe('cart_1'); + }); + }); + + describe('updateCartAddresses', () => { + it('should update cart with inline shipping and billing addresses', async () => { + const cartWithItems = { + ...minimalCart, + items: [minimalCartItem], + metadata: {}, + }; + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: cartWithItems }); + mockSdk.store.cart.update.mockResolvedValue({ cart: cartWithItems }); + + const shippingAddress = { + firstName: 'John', + lastName: 'Doe', + country: 'PL', + city: 'Warsaw', + postalCode: '00-001', + streetName: 'Main', + streetNumber: '1', + }; + const billingAddress = { ...shippingAddress, streetName: 'Billing St' }; + + const result = await firstValueFrom( + service.updateCartAddresses( + { cartId: 'cart_1' }, + { shippingAddress, billingAddress } as Carts.Request.UpdateCartAddressesBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.cart.update).toHaveBeenCalledWith( + 'cart_1', + expect.objectContaining({ + shipping_address: expect.objectContaining({ first_name: 'John', country_code: 'pl' }), + billing_address: expect.objectContaining({ address_1: 'Billing St' }), + }), + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + expect(result).toBeDefined(); + }); + + it('should throw BadRequestException when both addresses missing', async () => { + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); + + await expect( + firstValueFrom( + service.updateCartAddresses( + { cartId: 'cart_1' }, + {} as Carts.Request.UpdateCartAddressesBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('addShippingMethod', () => { + it('should add shipping method when cart has items', async () => { + const cartWithItems = { + ...minimalCart, + items: [minimalCartItem], + }; + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: cartWithItems }); + mockSdk.store.cart.addShippingMethod.mockResolvedValue({ cart: cartWithItems }); + + const result = await firstValueFrom( + service.addShippingMethod( + { cartId: 'cart_1' }, + { shippingOptionId: 'opt_1' } as Carts.Request.AddShippingMethodBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.cart.addShippingMethod).toHaveBeenCalledWith( + 'cart_1', + { option_id: 'opt_1' }, + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); + expect(result).toBeDefined(); + }); + + it('should throw BadRequestException when cart has no items', async () => { + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); + + await expect( + firstValueFrom( + service.addShippingMethod( + { cartId: 'cart_1' }, + { shippingOptionId: 'opt_1' } as Carts.Request.AddShippingMethodBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('applyPromotion', () => { + it('should call client.fetch with promo_codes', async () => { + mockSdk.client.fetch.mockResolvedValue({ cart: minimalCart }); + + const result = await firstValueFrom( + service.applyPromotion( + { cartId: 'cart_1' } as Carts.Request.ApplyPromotionParams, + { code: 'SAVE10' } as Carts.Request.ApplyPromotionBody, + 'Bearer token', + ), + ); + + expect(mockSdk.client.fetch).toHaveBeenCalledWith( + '/store/carts/cart_1/promotions', + expect.objectContaining({ + method: 'POST', + body: expect.objectContaining({ promo_codes: ['SAVE10'] }), + }), + ); + expect(result?.id).toBe('cart_1'); + }); + }); + + describe('removePromotion', () => { + it('should call client.fetch with promotion code in promo_codes', async () => { + mockSdk.client.fetch.mockResolvedValue({ cart: minimalCart }); + + const result = await firstValueFrom( + service.removePromotion( + { cartId: 'cart_1', code: 'SAVE10' } as Carts.Request.RemovePromotionParams, + 'Bearer token', + ), + ); + + expect(mockSdk.client.fetch).toHaveBeenCalledWith( + '/store/carts/cart_1/promotions', + expect.objectContaining({ + method: 'DELETE', + body: expect.objectContaining({ promo_codes: ['SAVE10'] }), + }), + ); + expect(result).toBeDefined(); + expect(result?.id).toBe('cart_1'); + }); + }); + + describe('prepareCheckout', () => { + it('should return cart when valid', async () => { + const cartWithItems = { + ...minimalCart, + items: [minimalCartItem], + }; + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: cartWithItems }); + + const result = await firstValueFrom( + service.prepareCheckout({ cartId: 'cart_1' } as Carts.Request.PrepareCheckoutParams, 'Bearer token'), + ); + + expect(result).toBeDefined(); + expect(result?.id).toBe('cart_1'); + }); + + it('should throw BadRequestException when cart has no items', async () => { + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); + + await expect( + firstValueFrom( + service.prepareCheckout( + { cartId: 'cart_1' } as Carts.Request.PrepareCheckoutParams, + 'Bearer token', + ), + ), + ).rejects.toThrow(BadRequestException); + await expect( + firstValueFrom( + service.prepareCheckout( + { cartId: 'cart_1' } as Carts.Request.PrepareCheckoutParams, + 'Bearer token', + ), + ), + ).rejects.toThrow('Cart must have items before preparing checkout'); + }); + }); + + describe('getCartList', () => { + it('should throw NotImplementedException', async () => { + await expect( + firstValueFrom(service.getCartList({ limit: 10, offset: 0 } as Carts.Request.GetCartListQuery)), + ).rejects.toThrow(NotImplementedException); + }); + }); + + describe('getCurrentCart', () => { + it('should throw NotImplementedException', async () => { + await expect(firstValueFrom(service.getCurrentCart('Bearer token'))).rejects.toThrow( + NotImplementedException, + ); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts new file mode 100644 index 000000000..d240edd48 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -0,0 +1,517 @@ +import Medusa from '@medusajs/js-sdk'; +import { HttpTypes } from '@medusajs/types'; +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, + NotImplementedException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Observable, catchError, forkJoin, from, map, of, switchMap, throwError } from 'rxjs'; + +import { LoggerService } from '@o2s/utils.logger'; + +import { Auth, Carts, Customers } from '@o2s/framework/modules'; + +import { Service as MedusaJsService } from '@/modules/medusajs'; + +import { handleHttpError } from '../../utils/handle-http-error'; +import { mapAddressToMedusa } from '../customers/customers.mapper'; + +import { mapCart } from './carts.mapper'; + +@Injectable() +export class CartsService extends Carts.Service { + private readonly sdk: Medusa; + private readonly defaultCurrency: string; + + private readonly cartItemsFields = '*items,*shipping_methods'; + + constructor( + private readonly config: ConfigService, + @Inject(LoggerService) protected readonly logger: LoggerService, + private readonly medusaJsService: MedusaJsService, + private readonly authService: Auth.Service, + private readonly customersService: Customers.Service, + ) { + super(); + this.sdk = this.medusaJsService.getSdk(); + this.defaultCurrency = this.config.get('DEFAULT_CURRENCY') || ''; + + if (!this.defaultCurrency) { + throw new InternalServerErrorException('DEFAULT_CURRENCY is not defined'); + } + } + + getCart(params: Carts.Request.GetCartParams, authorization?: string): Observable { + return from( + this.sdk.store.cart.retrieve( + params.id, + { fields: this.cartItemsFields }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StoreCartResponse) => { + const cart = mapCart(response.cart, this.defaultCurrency); + + // Verify ownership for customer carts + if ( + cart.customerId && + authorization && + cart.customerId !== this.authService.getCustomerId(authorization) + ) { + throw new UnauthorizedException('Unauthorized to access this cart'); + } + + return cart; + }), + catchError((error) => { + if (error.status === 404) { + throw new NotFoundException('Cart not found'); + } + return handleHttpError(error); + }), + ); + } + + getCartList(_query: Carts.Request.GetCartListQuery, _authorization?: string): Observable { + return throwError( + () => + new NotImplementedException( + 'Cart listing is not supported in Medusa.js integration. Medusa Store API does not support listing carts, and Admin API does not have a /admin/carts endpoint.', + ), + ); + } + + createCart(data: Carts.Request.CreateCartBody, authorization?: string): Observable { + return from( + this.sdk.store.cart.create( + { + currency_code: data.currency.toLowerCase(), + region_id: data.regionId, + metadata: data.metadata, + }, + { fields: this.cartItemsFields }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + updateCart( + params: Carts.Request.UpdateCartParams, + data: Carts.Request.UpdateCartBody, + authorization?: string, + ): Observable { + // Build metadata: merge existing with incoming, and store notes in metadata + const metadata: Record = { ...(data.metadata || {}) }; + if (data.notes !== undefined) { + metadata.notes = data.notes; + } + + // Build Medusa cart update payload with all supported fields + const cartUpdate: Partial = {}; + + if (data.regionId) { + cartUpdate.region_id = data.regionId; + } + if (data.email) { + cartUpdate.email = data.email; + } + if (Object.keys(metadata).length > 0) { + cartUpdate.metadata = metadata; + } + + return from( + this.sdk.store.cart.update( + params.id, + cartUpdate, + { fields: this.cartItemsFields }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + deleteCart(_params: Carts.Request.DeleteCartParams, _authorization?: string): Observable { + return throwError(() => new NotImplementedException('Delete cart is not supported in Medusa.js Store API.')); + } + + addCartItem(data: Carts.Request.AddCartItemBody, authorization?: string): Observable { + if (!data.sku) { + throw new BadRequestException('SKU is required for Medusa carts'); + } + + const customerId = authorization ? this.authService.getCustomerId(authorization) : undefined; + + // If cartId provided, use it (after verifying access) + if (data.cartId) { + const cartId = data.cartId; + return from( + this.sdk.store.cart.retrieve( + cartId, + { fields: this.cartItemsFields }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + switchMap((response: HttpTypes.StoreCartResponse) => { + const cart = mapCart(response.cart, this.defaultCurrency); + + if (cart.customerId && authorization && cart.customerId !== customerId) { + return throwError(() => new UnauthorizedException('Unauthorized to access this cart')); + } + + return from( + this.sdk.store.cart.createLineItem( + cartId, + { + variant_id: data.sku, + quantity: data.quantity, + metadata: data.metadata, + }, + { fields: this.cartItemsFields }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ); + }), + map((addResponse: HttpTypes.StoreCartResponse) => mapCart(addResponse.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + if (!data.currency) { + return throwError(() => new BadRequestException('Currency is required when creating a new cart')); + } + + return this.createCartAndAddItem( + data.currency, + data.sku, + data.quantity, + data.regionId, + data.metadata, + authorization, + ); + } + + updateCartItem( + params: Carts.Request.UpdateCartItemParams, + data: Carts.Request.UpdateCartItemBody, + authorization?: string, + ): Observable { + return from( + this.sdk.store.cart.updateLineItem( + params.cartId, + params.itemId, + { + quantity: data.quantity, + metadata: data.metadata, + }, + { fields: this.cartItemsFields }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + removeCartItem(params: Carts.Request.RemoveCartItemParams, authorization?: string): Observable { + return from( + this.sdk.store.cart.deleteLineItem( + params.cartId, + params.itemId, + { fields: this.cartItemsFields }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StoreLineItemDeleteResponse) => { + if (!response.parent) { + throw new NotFoundException('Cart not found after item removal'); + } + return mapCart(response.parent, this.defaultCurrency); + }), + catchError((error) => handleHttpError(error)), + ); + } + + applyPromotion( + params: Carts.Request.ApplyPromotionParams, + data: Carts.Request.ApplyPromotionBody, + authorization?: string, + ): Observable { + return from( + this.sdk.client.fetch(`/store/carts/${params.cartId}/promotions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.medusaJsService.getStoreApiHeaders(authorization), + }, + body: { + promo_codes: [data.code], + }, + query: { fields: this.cartItemsFields }, + }), + ).pipe( + map((response) => mapCart(response.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + removePromotion(params: Carts.Request.RemovePromotionParams, authorization?: string): Observable { + return from( + this.sdk.client.fetch(`/store/carts/${params.cartId}/promotions`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...this.medusaJsService.getStoreApiHeaders(authorization), + }, + body: { + promo_codes: [params.code], + }, + query: { fields: this.cartItemsFields }, + }), + ).pipe( + map((response) => mapCart(response.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + getCurrentCart(_authorization?: string): Observable { + return throwError( + () => + new NotImplementedException( + 'Getting current cart is not supported in Medusa.js integration. Medusa does not provide a way to query carts by customer.', + ), + ); + } + + prepareCheckout(params: Carts.Request.PrepareCheckoutParams, authorization?: string): Observable { + return this.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + // Verify ownership for customer carts + if (cart.customerId && authorization) { + const customerId = this.authService.getCustomerId(authorization); + if (cart.customerId !== customerId) { + return throwError( + () => new UnauthorizedException('Unauthorized to prepare checkout for this cart'), + ); + } + } + + // Validate cart has items + if (!cart.items || cart.items.data.length === 0) { + return throwError(() => new BadRequestException('Cart must have items before preparing checkout')); + } + + return of(cart); + }), + ); + } + + private createCartAndAddItem( + currency: string, + sku: string, + quantity: number, + regionId?: string, + metadata?: Record, + authorization?: string, + ): Observable { + return from( + this.sdk.store.cart.create( + { + currency_code: currency.toLowerCase(), + region_id: regionId, + metadata, + }, + { fields: this.cartItemsFields }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + switchMap((createResponse: HttpTypes.StoreCartResponse) => + from( + this.sdk.store.cart.createLineItem( + createResponse.cart.id, + { + variant_id: sku, + quantity, + metadata, + }, + { fields: this.cartItemsFields }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ), + ), + map((addResponse: HttpTypes.StoreCartResponse) => mapCart(addResponse.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + updateCartAddresses( + params: Carts.Request.UpdateCartAddressesParams, + data: Carts.Request.UpdateCartAddressesBody, + authorization?: string, + ): Observable { + const headers = this.medusaJsService.getStoreApiHeaders(authorization); + + // Resolve shipping address + const shippingAddress$ = + data.shippingAddressId && authorization + ? this.customersService.getAddress({ id: data.shippingAddressId }, authorization).pipe( + map((address) => { + if (!address) { + throw new NotFoundException(`Address with ID ${data.shippingAddressId} not found`); + } + return mapAddressToMedusa(address.address); + }), + ) + : data.shippingAddress + ? of(mapAddressToMedusa(data.shippingAddress)) + : of(null); + + // Resolve billing address + const billingAddress$ = + data.billingAddressId && authorization + ? this.customersService.getAddress({ id: data.billingAddressId }, authorization).pipe( + map((address) => { + if (!address) { + throw new NotFoundException(`Address with ID ${data.billingAddressId} not found`); + } + return mapAddressToMedusa(address.address); + }), + ) + : data.billingAddress + ? of(mapAddressToMedusa(data.billingAddress)) + : of(null); + + // Get current cart to merge metadata + return this.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + // Resolve both addresses in parallel + return forkJoin([shippingAddress$, billingAddress$]).pipe( + switchMap(([shippingAddress, billingAddress]) => { + // At least one address must be provided + if (!shippingAddress && !billingAddress) { + return throwError( + () => new BadRequestException('At least one address (shipping or billing) is required'), + ); + } + + // Build metadata (notes only; email goes directly on cart) + const metadata = this.buildCartMetadata(data.notes, cart.metadata); + + // Build cart update payload + const cartUpdate: Partial = { + metadata, + }; + + // Set email directly on cart (for guest checkout) + if (data.email) { + cartUpdate.email = data.email; + } + + // Set addresses (use shipping as billing if billing not provided) + if (shippingAddress) { + cartUpdate.shipping_address = shippingAddress; + cartUpdate.billing_address = billingAddress ?? shippingAddress; + } else if (billingAddress) { + // If only billing provided, use it for both + cartUpdate.shipping_address = billingAddress; + cartUpdate.billing_address = billingAddress; + } + + // Update cart + return from( + this.sdk.store.cart.update( + params.cartId, + cartUpdate, + { fields: this.cartItemsFields }, + headers, + ), + ).pipe( + switchMap(() => this.getCart({ id: params.cartId }, authorization)), + map((updatedCart) => { + if (!updatedCart) { + throw new NotFoundException(`Cart with ID ${params.cartId} not found`); + } + return updatedCart; + }), + ); + }), + ); + }), + catchError((error) => handleHttpError(error)), + ); + } + + addShippingMethod( + params: Carts.Request.AddShippingMethodParams, + data: Carts.Request.AddShippingMethodBody, + authorization?: string, + ): Observable { + const headers = authorization + ? this.medusaJsService.getStoreApiHeaders(authorization) + : this.medusaJsService.getStoreApiHeaders(authorization); + + // Verify cart exists + return this.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + if (!cart.items || cart.items.data.length === 0) { + return throwError( + () => new BadRequestException('Cart must have items before adding shipping method'), + ); + } + + // Add shipping method using SDK + return from( + this.sdk.store.cart.addShippingMethod( + params.cartId, + { option_id: data.shippingOptionId }, + { fields: this.cartItemsFields }, + headers, + ), + ).pipe( + switchMap(() => this.getCart({ id: params.cartId }, authorization)), + map((updatedCart) => { + if (!updatedCart) { + throw new NotFoundException(`Cart with ID ${params.cartId} not found`); + } + return updatedCart; + }), + ); + }), + catchError((error) => handleHttpError(error)), + ); + } + + /** + * Builds cart metadata immutably by merging optional notes + * into existing metadata without mutating any arguments. + * Email is passed directly on the cart (not in metadata). + */ + private buildCartMetadata(notes?: string, existingMetadata?: Record): Record { + const metadata: Record = { ...(existingMetadata || {}) }; + if (notes !== undefined) { + metadata.notes = notes; + } + return metadata; + } +} diff --git a/packages/integrations/medusajs/src/modules/carts/index.ts b/packages/integrations/medusajs/src/modules/carts/index.ts new file mode 100644 index 000000000..5b18b302c --- /dev/null +++ b/packages/integrations/medusajs/src/modules/carts/index.ts @@ -0,0 +1,6 @@ +import { Carts } from '@o2s/framework/modules'; + +export { CartsService as Service } from './carts.service'; + +export import Request = Carts.Request; +export import Model = Carts.Model; diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts new file mode 100644 index 000000000..36a943a21 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts @@ -0,0 +1,205 @@ +import { BadRequestException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; + +import { Carts, Orders, Payments } from '@o2s/framework/modules'; + +import { mapCheckoutSummary, mapPlaceOrderResponse, mapShippingOptions } from './checkout.mapper'; + +function minimalCartWithAllFields(): Carts.Model.Cart { + const address = { + firstName: 'John', + lastName: 'Doe', + country: 'PL', + district: 'Mazovia', + region: 'Mazovia', + streetName: 'Street', + streetNumber: '1', + apartment: undefined, + city: 'Warsaw', + postalCode: '00-001', + phone: '+48', + }; + const price = { value: 10000, currency: 'EUR' as const }; + const shippingMethod: Orders.Model.ShippingMethod = { + id: 'sm_1', + name: 'Express', + description: 'Fast', + total: price, + subtotal: price, + }; + const paymentMethod: Carts.Model.PaymentMethod = { + id: 'pm_1', + name: 'Card', + }; + return { + id: 'cart_1', + currency: 'EUR', + items: { data: [], total: 0 }, + subtotal: price, + shippingTotal: price, + taxTotal: price, + discountTotal: price, + total: price, + shippingAddress: address, + billingAddress: address, + shippingMethod, + paymentMethod, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: {}, + type: 'ACTIVE', + } as unknown as Carts.Model.Cart; +} + +describe('checkout.mapper', () => { + describe('mapCheckoutSummary', () => { + it('should return summary when cart has all required fields', () => { + const cart = minimalCartWithAllFields(); + const result = mapCheckoutSummary(cart); + expect(result.cart).toBe(cart); + expect(result.shippingAddress).toBe(cart.shippingAddress); + expect(result.billingAddress).toBe(cart.billingAddress); + expect(result.shippingMethod).toBe(cart.shippingMethod); + expect(result.paymentMethod).toBe(cart.paymentMethod); + expect(result.totals.subtotal).toBe(cart.subtotal); + expect(result.totals.shipping).toBe(cart.shippingTotal); + expect(result.totals.tax).toBe(cart.taxTotal); + expect(result.totals.discount).toBe(cart.discountTotal); + expect(result.totals.total).toBe(cart.total); + }); + + it('should throw BadRequestException when shippingAddress is missing', () => { + const cart = minimalCartWithAllFields(); + cart.shippingAddress = undefined; + expect(() => mapCheckoutSummary(cart)).toThrow(BadRequestException); + expect(() => mapCheckoutSummary(cart)).toThrow('Shipping address is required'); + }); + + it('should throw BadRequestException when billingAddress is missing', () => { + const cart = minimalCartWithAllFields(); + cart.billingAddress = undefined; + expect(() => mapCheckoutSummary(cart)).toThrow(BadRequestException); + expect(() => mapCheckoutSummary(cart)).toThrow('Billing address is required'); + }); + + it('should throw BadRequestException when shippingMethod is missing', () => { + const cart = minimalCartWithAllFields(); + cart.shippingMethod = undefined; + expect(() => mapCheckoutSummary(cart)).toThrow(BadRequestException); + expect(() => mapCheckoutSummary(cart)).toThrow('Shipping method is required'); + }); + + it('should throw BadRequestException when paymentMethod is missing', () => { + const cart = minimalCartWithAllFields(); + cart.paymentMethod = undefined; + expect(() => mapCheckoutSummary(cart)).toThrow(BadRequestException); + expect(() => mapCheckoutSummary(cart)).toThrow('Payment method is required'); + }); + + it('should throw BadRequestException when subtotal is missing', () => { + const cart = minimalCartWithAllFields(); + cart.subtotal = undefined; + expect(() => mapCheckoutSummary(cart)).toThrow(BadRequestException); + expect(() => mapCheckoutSummary(cart)).toThrow('Cart subtotal is required'); + }); + + it('should throw BadRequestException when shippingTotal is missing', () => { + const cart = minimalCartWithAllFields(); + cart.shippingTotal = undefined; + expect(() => mapCheckoutSummary(cart)).toThrow(BadRequestException); + expect(() => mapCheckoutSummary(cart)).toThrow('Shipping total is required'); + }); + + it('should throw BadRequestException when taxTotal is missing', () => { + const cart = minimalCartWithAllFields(); + cart.taxTotal = undefined; + expect(() => mapCheckoutSummary(cart)).toThrow(BadRequestException); + expect(() => mapCheckoutSummary(cart)).toThrow('Tax total is required'); + }); + + it('should throw BadRequestException when discountTotal is missing', () => { + const cart = minimalCartWithAllFields(); + cart.discountTotal = undefined; + expect(() => mapCheckoutSummary(cart)).toThrow(BadRequestException); + expect(() => mapCheckoutSummary(cart)).toThrow('Discount total is required'); + }); + + it('should throw BadRequestException when total is missing', () => { + const cart = { ...minimalCartWithAllFields(), total: undefined } as unknown as Carts.Model.Cart; + expect(() => mapCheckoutSummary(cart)).toThrow(BadRequestException); + expect(() => mapCheckoutSummary(cart)).toThrow('Cart total is required'); + }); + }); + + describe('mapPlaceOrderResponse', () => { + it('should return order and paymentRedirectUrl from paymentSession', () => { + const order = { id: 'order_1', status: 'COMPLETED' } as Orders.Model.Order; + const paymentSession = { + id: 'ps_1', + redirectUrl: 'https://example.com/redirect', + } as Payments.Model.PaymentSession; + const result = mapPlaceOrderResponse(order, paymentSession); + expect(result.order).toBe(order); + expect(result.paymentRedirectUrl).toBe('https://example.com/redirect'); + }); + + it('should return order without paymentRedirectUrl when paymentSession is undefined', () => { + const order = { id: 'order_1', status: 'COMPLETED' } as Orders.Model.Order; + const result = mapPlaceOrderResponse(order); + expect(result.order).toBe(order); + expect(result.paymentRedirectUrl).toBeUndefined(); + }); + }); + + describe('mapShippingOptions', () => { + it('should map shipping options', () => { + const options = [ + { + id: 'opt_1', + name: 'Standard', + amount: 500, + calculated_price: { + calculated_amount: 500, + currency_code: 'eur', + calculated_amount_without_tax: 450, + }, + type: { label: 'Standard shipping' }, + }, + ]; + const result = mapShippingOptions(options as never, 'EUR'); + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe('opt_1'); + expect(result.data[0]?.name).toBe('Standard'); + expect(result.total).toBe(1); + }); + + it('should throw when option has no price', () => { + const options = [ + { + id: 'opt_1', + name: 'Broken', + amount: undefined, + calculated_price: undefined, + }, + ] as unknown as Parameters[0]; + expect(() => mapShippingOptions(options, 'EUR')).toThrow(BadRequestException); + expect(() => mapShippingOptions(options, 'EUR')).toThrow('no price information'); + }); + + it('should use default currency when option has no currency', () => { + const options = [ + { + id: 'opt_1', + name: 'Standard', + amount: 500, + calculated_price: { calculated_amount: 500, currency_code: undefined }, + }, + ] as unknown as Parameters[0]; + const result = mapShippingOptions(options, 'EUR'); + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe('opt_1'); + expect(result.data[0]?.total?.currency).toBe('EUR'); + expect(result.data[0]?.subtotal?.currency).toBe('EUR'); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts new file mode 100644 index 000000000..1c5d1d0b1 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts @@ -0,0 +1,116 @@ +import { HttpTypes } from '@medusajs/types'; +import { BadRequestException } from '@nestjs/common'; + +import { Carts, Checkout, Orders, Payments } from '@o2s/framework/modules'; + +import { parseCurrency } from '@/utils/currency'; + +export function mapCheckoutSummary( + cart: Carts.Model.Cart, + _paymentSession?: Payments.Model.PaymentSession, +): Checkout.Model.CheckoutSummary { + if (!cart.shippingAddress) { + throw new BadRequestException('Shipping address is required for checkout summary'); + } + + if (!cart.billingAddress) { + throw new BadRequestException('Billing address is required for checkout summary'); + } + + if (!cart.shippingMethod) { + throw new BadRequestException('Shipping method is required for checkout summary'); + } + + if (!cart.paymentMethod) { + throw new BadRequestException('Payment method is required for checkout summary'); + } + + if (!cart.subtotal) { + throw new BadRequestException('Cart subtotal is required for checkout summary'); + } + + if (!cart.shippingTotal) { + throw new BadRequestException('Shipping total is required for checkout summary'); + } + + if (!cart.taxTotal) { + throw new BadRequestException('Tax total is required for checkout summary'); + } + + if (!cart.discountTotal) { + throw new BadRequestException('Discount total is required for checkout summary'); + } + + if (!cart.total) { + throw new BadRequestException('Cart total is required for checkout summary'); + } + + return { + cart, + shippingAddress: cart.shippingAddress, + billingAddress: cart.billingAddress, + shippingMethod: cart.shippingMethod, + paymentMethod: cart.paymentMethod, + totals: { + subtotal: cart.subtotal, + shipping: cart.shippingTotal, + tax: cart.taxTotal, + discount: cart.discountTotal, + total: cart.total, + }, + notes: cart.notes, + email: cart.email, + }; +} + +export function mapPlaceOrderResponse( + order: Orders.Model.Order, + paymentSession?: Payments.Model.PaymentSession, +): Checkout.Model.PlaceOrderResponse { + return { + order, + paymentRedirectUrl: paymentSession?.redirectUrl, + }; +} + +export function mapShippingOptions( + options: HttpTypes.StoreCartShippingOption[], + defaultCurrency: string, +): Checkout.Model.ShippingOptions { + return { + data: options.map((option) => mapShippingOption(option, defaultCurrency)), + total: options.length, + }; +} + +function mapShippingOption( + option: HttpTypes.StoreCartShippingOption, + defaultCurrency: string, +): Orders.Model.ShippingMethod { + const calculatedPrice = option.calculated_price; + + const amount = calculatedPrice?.calculated_amount ?? option.amount; + if (amount === undefined || amount === null) { + throw new BadRequestException(`Shipping option ${option.id} has no price information`); + } + + const currencyCode = (calculatedPrice?.currency_code || defaultCurrency)?.toUpperCase(); + + const currency = parseCurrency(currencyCode); + + const amountWithoutTax = calculatedPrice?.calculated_amount_without_tax ?? amount; + + return { + id: option.id, + name: option.name, + description: option.type?.label || option.type?.description || undefined, + total: { + value: amount, + currency, + }, + subtotal: { + value: amountWithoutTax, + currency, + }, + }; +} diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts new file mode 100644 index 000000000..afc1384a1 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts @@ -0,0 +1,419 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom, of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Carts, Checkout, Payments } from '@o2s/framework/modules'; + +import { CheckoutService } from './checkout.service'; + +const DEFAULT_CURRENCY = 'EUR'; + +const mappedCart = { + id: 'cart_1', + items: { data: [{ id: 'item_1' }], total: 1 }, + shippingAddress: {}, + billingAddress: {}, + shippingMethod: {}, + paymentMethod: {}, + subtotal: { value: 9000 }, + shippingTotal: { value: 0 }, + taxTotal: { value: 1000 }, + discountTotal: { value: 0 }, + total: { value: 10000 }, +} as unknown as Carts.Model.Cart; + +const mockPaymentSession = { + id: 'ps_1', + providerId: 'manual', +} as Payments.Model.PaymentSession; + +describe('CheckoutService', () => { + let service: CheckoutService; + let mockCartsService: { + getCart: ReturnType; + updateCart: ReturnType; + updateCartAddresses: ReturnType; + addShippingMethod: ReturnType; + }; + let mockPaymentsService: { + createSession: ReturnType; + getSession: ReturnType; + }; + let mockSdk: { + store: { + cart: { complete: ReturnType }; + fulfillment: { + listCartOptions: ReturnType; + calculate: ReturnType; + }; + }; + }; + let mockMedusaJsService: { getSdk: ReturnType; getStoreApiHeaders: ReturnType }; + let mockConfig: { get: ReturnType }; + let mockLogger: { debug: ReturnType; warn: ReturnType }; + + beforeEach(() => { + vi.restoreAllMocks(); + mockCartsService = { + getCart: vi.fn(), + updateCart: vi.fn(), + updateCartAddresses: vi.fn(), + addShippingMethod: vi.fn(), + }; + mockPaymentsService = { + createSession: vi.fn(), + getSession: vi.fn(), + }; + mockSdk = { + store: { + cart: { complete: vi.fn() }, + fulfillment: { + listCartOptions: vi.fn(), + calculate: vi.fn(), + }, + }, + }; + mockMedusaJsService = { + getSdk: vi.fn(() => mockSdk), + getStoreApiHeaders: vi.fn(() => ({})), + }; + mockConfig = { + get: vi.fn((key: string) => (key === 'DEFAULT_CURRENCY' ? DEFAULT_CURRENCY : '')), + }; + mockLogger = { debug: vi.fn(), warn: vi.fn() }; + + service = new CheckoutService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockConfig as unknown as ConfigService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + mockCartsService as unknown as Carts.Service, + mockPaymentsService as unknown as Payments.Service, + ); + }); + + describe('setAddresses', () => { + it('should delegate to cartsService.updateCartAddresses after validation', async () => { + vi.mocked(mockCartsService.getCart).mockReturnValue(of(mappedCart)); + vi.mocked(mockCartsService.updateCartAddresses).mockReturnValue(of(mappedCart)); + + const data = { + shippingAddress: { + firstName: 'John', + lastName: 'Doe', + country: 'PL', + city: 'Warsaw', + postalCode: '00-001', + }, + }; + const result = await firstValueFrom( + service.setAddresses({ cartId: 'cart_1' }, data as Checkout.Request.SetAddressesBody, 'Bearer token'), + ); + + expect(mockCartsService.getCart).toHaveBeenCalledWith({ id: 'cart_1' }, 'Bearer token'); + expect(mockCartsService.updateCartAddresses).toHaveBeenCalledWith( + { cartId: 'cart_1' }, + data, + 'Bearer token', + ); + expect(result).toBe(mappedCart); + }); + + it('should throw NotFoundException when cart not found', async () => { + vi.mocked(mockCartsService.getCart).mockReturnValue(of(undefined)); + + await expect( + firstValueFrom( + service.setAddresses( + { cartId: 'cart_1' }, + { shippingAddress: {} } as Checkout.Request.SetAddressesBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(NotFoundException); + expect(mockCartsService.updateCartAddresses).not.toHaveBeenCalled(); + }); + + it('should throw when cart has no items', async () => { + const emptyCart = { ...mappedCart, items: { data: [], total: 0 } }; + vi.mocked(mockCartsService.getCart).mockReturnValue(of(emptyCart)); + + await expect( + firstValueFrom( + service.setAddresses( + { cartId: 'cart_1' }, + { shippingAddress: {} } as Checkout.Request.SetAddressesBody, + 'Bearer token', + ), + ), + ).rejects.toThrow('Cart must have items before checkout'); + expect(mockCartsService.updateCartAddresses).not.toHaveBeenCalled(); + }); + }); + + describe('setShippingMethod', () => { + it('should delegate to cartsService.addShippingMethod', async () => { + vi.mocked(mockCartsService.getCart).mockReturnValue(of(mappedCart)); + vi.mocked(mockCartsService.addShippingMethod).mockReturnValue(of(mappedCart)); + + const result = await firstValueFrom( + service.setShippingMethod({ cartId: 'cart_1' }, { shippingOptionId: 'opt_1' }, 'Bearer token'), + ); + + expect(mockCartsService.getCart).toHaveBeenCalledWith({ id: 'cart_1' }, 'Bearer token'); + expect(mockCartsService.addShippingMethod).toHaveBeenCalledWith( + { cartId: 'cart_1' }, + { shippingOptionId: 'opt_1' }, + 'Bearer token', + ); + expect(result).toBe(mappedCart); + }); + }); + + describe('setPayment', () => { + it('should delegate to paymentsService.createSession and cartsService.updateCart', async () => { + vi.mocked(mockPaymentsService.createSession).mockReturnValue(of(mockPaymentSession)); + vi.mocked(mockCartsService.getCart).mockReturnValue(of(mappedCart)); + vi.mocked(mockCartsService.updateCart).mockReturnValue(of(mappedCart)); + + const result = await firstValueFrom( + service.setPayment( + { cartId: 'cart_1' }, + { providerId: 'manual' } as Checkout.Request.SetPaymentBody, + 'Bearer token', + ), + ); + + expect(mockPaymentsService.createSession).toHaveBeenCalledWith( + expect.objectContaining({ cartId: 'cart_1', providerId: 'manual' }), + 'Bearer token', + ); + expect(mockCartsService.getCart).toHaveBeenCalledWith({ id: 'cart_1' }, 'Bearer token'); + expect(mockCartsService.updateCart).toHaveBeenCalledWith( + { id: 'cart_1' }, + expect.objectContaining({ + metadata: expect.objectContaining({ + paymentSessionId: 'ps_1', + paymentMethod: expect.objectContaining({ id: 'manual' }), + }), + }), + 'Bearer token', + ); + expect(result).toBe(mockPaymentSession); + }); + }); + + describe('getCheckoutSummary', () => { + it('should return mapCheckoutSummary(cart) when no payment session', async () => { + vi.mocked(mockCartsService.getCart).mockReturnValue(of(mappedCart)); + + const result = await firstValueFrom( + service.getCheckoutSummary( + { cartId: 'cart_1' } as Checkout.Request.GetCheckoutSummaryParams, + 'Bearer token', + ), + ); + + expect(mockCartsService.getCart).toHaveBeenCalledWith({ id: 'cart_1' }, 'Bearer token'); + expect(mockPaymentsService.getSession).not.toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should call paymentsService.getSession when cart has paymentSessionId', async () => { + const cartWithPayment = { ...mappedCart, metadata: { paymentSessionId: 'ps_1' } }; + vi.mocked(mockCartsService.getCart).mockReturnValue(of(cartWithPayment)); + vi.mocked(mockPaymentsService.getSession).mockReturnValue(of(mockPaymentSession)); + + const result = await firstValueFrom( + service.getCheckoutSummary( + { cartId: 'cart_1' } as Checkout.Request.GetCheckoutSummaryParams, + 'Bearer token', + ), + ); + + expect(mockPaymentsService.getSession).toHaveBeenCalledWith({ id: 'ps_1' }, 'Bearer token'); + expect(result).toBeDefined(); + }); + }); + + describe('placeOrder', () => { + it('should call SDK cart.complete and return mapPlaceOrderResponse', async () => { + vi.mocked(mockCartsService.getCart).mockReturnValue(of(mappedCart)); + mockSdk.store.cart.complete.mockResolvedValue({ + type: 'order', + order: { + id: 'order_1', + status: 'completed', + payment_status: 'captured', + currency_code: 'eur', + total: 10000, + subtotal: 9000, + tax_total: 1000, + discount_total: 0, + shipping_total: 0, + items: [], + shipping_address: null, + billing_address: null, + shipping_methods: [], + created_at: new Date(), + updated_at: new Date(), + }, + }); + + const result = await firstValueFrom(service.placeOrder({ cartId: 'cart_1' }, undefined, 'Bearer token')); + + expect(mockSdk.store.cart.complete).toHaveBeenCalledWith('cart_1', {}, expect.any(Object)); + expect(result.order).toBeDefined(); + expect(result.order?.id).toBe('order_1'); + expect(result.paymentRedirectUrl).toBeUndefined(); + }); + + it('should throw BadRequestException when shipping or billing address missing', async () => { + const cartNoAddress = { ...mappedCart, shippingAddress: undefined, billingAddress: undefined }; + vi.mocked(mockCartsService.getCart).mockReturnValue(of(cartNoAddress)); + + await expect( + firstValueFrom(service.placeOrder({ cartId: 'cart_1' }, undefined, 'Bearer token')), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when shipping method missing', async () => { + const cartNoShipping = { ...mappedCart, shippingMethod: undefined }; + vi.mocked(mockCartsService.getCart).mockReturnValue(of(cartNoShipping)); + + await expect( + firstValueFrom(service.placeOrder({ cartId: 'cart_1' }, undefined, 'Bearer token')), + ).rejects.toThrow(BadRequestException); + }); + + it('should call updateCart when email differs from cart email before placing order', async () => { + const cartWithDifferentEmail = { ...mappedCart, email: 'old@test.com' }; + vi.mocked(mockCartsService.getCart).mockReturnValue(of(cartWithDifferentEmail)); + vi.mocked(mockCartsService.updateCart).mockReturnValue( + of({ ...cartWithDifferentEmail, email: 'new@test.com' }), + ); + mockSdk.store.cart.complete.mockResolvedValue({ + type: 'order', + order: { + id: 'order_1', + status: 'completed', + payment_status: 'captured', + currency_code: 'eur', + total: 10000, + subtotal: 9000, + tax_total: 1000, + discount_total: 0, + shipping_total: 0, + items: [], + shipping_address: null, + billing_address: null, + shipping_methods: [], + created_at: new Date(), + updated_at: new Date(), + }, + }); + + const result = await firstValueFrom( + service.placeOrder( + { cartId: 'cart_1' }, + { email: 'new@test.com' } as Checkout.Request.PlaceOrderBody, + 'Bearer token', + ), + ); + + expect(mockCartsService.updateCart).toHaveBeenCalledWith( + { id: 'cart_1' }, + { email: 'new@test.com' }, + 'Bearer token', + ); + expect(result.order).toBeDefined(); + }); + + it('should throw BadRequestException when cart.complete returns non-order response', async () => { + vi.mocked(mockCartsService.getCart).mockReturnValue(of(mappedCart)); + mockSdk.store.cart.complete.mockResolvedValue({ type: 'cart', cart: {} }); + + await expect( + firstValueFrom(service.placeOrder({ cartId: 'cart_1' }, undefined, 'Bearer token')), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when cart.complete returns 400', async () => { + vi.mocked(mockCartsService.getCart).mockReturnValue(of(mappedCart)); + mockSdk.store.cart.complete.mockRejectedValue({ + response: { status: 400, data: { message: 'Payment required' } }, + }); + + await expect( + firstValueFrom(service.placeOrder({ cartId: 'cart_1' }, undefined, 'Bearer token')), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getShippingOptions', () => { + it('should return flat-price options directly when no calculated options', async () => { + mockSdk.store.fulfillment.listCartOptions.mockResolvedValue({ + shipping_options: [ + { + id: 'opt_1', + name: 'Standard', + price_type: 'flat', + amount: 500, + calculated_price: { + calculated_amount: 500, + currency_code: 'eur', + calculated_amount_without_tax: 450, + }, + }, + ], + }); + + const result = await firstValueFrom( + service.getShippingOptions( + { cartId: 'cart_1' } as Checkout.Request.GetShippingOptionsParams, + 'Bearer token', + ), + ); + + expect(mockSdk.store.fulfillment.listCartOptions).toHaveBeenCalledWith( + { cart_id: 'cart_1' }, + expect.any(Object), + ); + expect(mockSdk.store.fulfillment.calculate).not.toHaveBeenCalled(); + expect(result.data).toBeDefined(); + expect(result.data.length).toBe(1); + }); + + it('should fallback to original option when fulfillment.calculate fails', async () => { + const originalOption = { + id: 'opt_calc', + name: 'Calculated', + price_type: 'calculated', + amount: 0, + calculated_price: { + calculated_amount: 0, + currency_code: 'eur', + calculated_amount_without_tax: 0, + }, + }; + mockSdk.store.fulfillment.listCartOptions.mockResolvedValue({ + shipping_options: [originalOption], + }); + mockSdk.store.fulfillment.calculate.mockRejectedValue(new Error('Calculation failed')); + + const result = await firstValueFrom( + service.getShippingOptions( + { cartId: 'cart_1' } as Checkout.Request.GetShippingOptionsParams, + 'Bearer token', + ), + ); + + expect(mockSdk.store.fulfillment.calculate).toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to calculate price for shipping option opt_calc', + expect.any(Error), + ); + expect(result.data).toBeDefined(); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts new file mode 100644 index 000000000..1a3810691 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts @@ -0,0 +1,361 @@ +import Medusa from '@medusajs/js-sdk'; +import { HttpTypes } from '@medusajs/types'; +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Observable, catchError, forkJoin, from, map, of, switchMap, throwError } from 'rxjs'; + +import { LoggerService } from '@o2s/utils.logger'; + +import { Carts, Checkout, Payments } from '@o2s/framework/modules'; + +import { Service as MedusaJsService } from '@/modules/medusajs'; + +import { handleHttpError } from '../../utils/handle-http-error'; +import { mapOrder } from '../orders/orders.mapper'; + +import { mapCheckoutSummary, mapPlaceOrderResponse, mapShippingOptions } from './checkout.mapper'; + +@Injectable() +export class CheckoutService extends Checkout.Service { + private readonly sdk: Medusa; + private readonly defaultCurrency: string; + + constructor( + @Inject(LoggerService) protected readonly logger: LoggerService, + private readonly config: ConfigService, + private readonly medusaJsService: MedusaJsService, + private readonly cartsService: Carts.Service, + private readonly paymentsService: Payments.Service, + ) { + super(); + this.sdk = this.medusaJsService.getSdk(); + this.defaultCurrency = this.config.get('DEFAULT_CURRENCY') || ''; + } + + setAddresses( + params: Checkout.Request.SetAddressesParams, + data: Checkout.Request.SetAddressesBody, + authorization: string | undefined, + ): Observable { + // Validate cart exists and has items + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + if (!cart.items || cart.items.data.length === 0) { + return throwError(() => new BadRequestException('Cart must have items before checkout')); + } + + // Delegate to cart service + return this.cartsService.updateCartAddresses({ cartId: params.cartId }, data, authorization); + }), + catchError((error) => handleHttpError(error)), + ); + } + + setShippingMethod( + params: Checkout.Request.SetShippingMethodParams, + data: Checkout.Request.SetShippingMethodBody, + authorization: string | undefined, + ): Observable { + // Validate cart exists and has items + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + if (!cart.items || cart.items.data.length === 0) { + return throwError( + () => new BadRequestException('Cart must have items before adding shipping method'), + ); + } + + // Delegate to cart service + return this.cartsService.addShippingMethod( + { cartId: params.cartId }, + { shippingOptionId: data.shippingOptionId }, + authorization, + ); + }), + catchError((error) => handleHttpError(error)), + ); + } + + setPayment( + params: Checkout.Request.SetPaymentParams, + data: Checkout.Request.SetPaymentBody, + authorization: string | undefined, + ): Observable { + return this.paymentsService + .createSession( + { + cartId: params.cartId, + providerId: data.providerId, + returnUrl: data.returnUrl, + cancelUrl: data.cancelUrl, + metadata: data.metadata, + }, + authorization, + ) + .pipe( + switchMap((session) => { + // Get cart to preserve existing metadata + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError( + () => new NotFoundException(`Cart with ID ${params.cartId} not found`), + ); + } + // Update cart with payment session ID and payment method, preserving existing metadata + return this.cartsService + .updateCart( + { id: params.cartId }, + { + metadata: { + ...cart.metadata, + paymentSessionId: session.id, + paymentMethod: { + id: session.providerId, + name: session.providerId, + type: 'OTHER', + }, + }, + }, + authorization, + ) + .pipe(map(() => session)); + }), + ); + }), + ); + } + + getCheckoutSummary( + params: Checkout.Request.GetCheckoutSummaryParams, + authorization: string | undefined, + ): Observable { + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + const id = cart.metadata?.paymentSessionId; + const paymentSessionId = typeof id === 'string' ? id : undefined; + + if (paymentSessionId) { + return this.paymentsService + .getSession({ id: paymentSessionId }, authorization) + .pipe( + catchError(() => { + return of(undefined); + }), + ) + .pipe(map((session) => mapCheckoutSummary(cart, session))); + } + + return of(mapCheckoutSummary(cart)); + }), + ); + } + + placeOrder( + params: Checkout.Request.PlaceOrderParams, + data: Checkout.Request.PlaceOrderBody | undefined, + authorization: string | undefined, + ): Observable { + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + // Validate required data + if (!cart.shippingAddress || !cart.billingAddress) { + return throwError(() => new BadRequestException('Shipping and billing addresses are required')); + } + + if (!cart.shippingMethod) { + return throwError(() => new BadRequestException('Shipping method is required')); + } + + if (!cart.paymentMethod) { + return throwError(() => new BadRequestException('Payment method is required')); + } + + // Set email on cart if provided (for guest checkout) + const email = data?.email || cart.email; + if (email && email !== cart.email) { + return this.cartsService + .updateCart({ id: params.cartId }, { email }, authorization) + .pipe(switchMap(() => this.completeCartAndCreateOrder(params.cartId, email, authorization))); + } + + return this.completeCartAndCreateOrder(params.cartId, email, authorization); + }), + ); + } + + private completeCartAndCreateOrder( + cartId: string, + email: string | undefined, + authorization: string | undefined, + ): Observable { + // Complete the cart in Medusa (this creates the order) + return from( + this.sdk.store.cart.complete(cartId, {}, this.medusaJsService.getStoreApiHeaders(authorization)), + ).pipe( + switchMap((response: HttpTypes.StoreCompleteCartResponse) => { + if (response.type !== 'order' || !response.order) { + return throwError(() => new BadRequestException('Failed to create order from cart')); + } + + // Use the order directly from the cart.complete response + // This avoids a separate getOrder call which requires authorization (fails for guests) + const order = mapOrder(response.order, this.defaultCurrency); + + // Attach email for order confirmation if provided (guest checkout) + if (email) { + order.email = email; + } + + return of(mapPlaceOrderResponse(order)); + }), + catchError((error) => { + if (error.response?.status === 400) { + return throwError( + () => new BadRequestException(error.response.data?.message || 'Failed to complete cart'), + ); + } + return handleHttpError(error); + }), + ); + } + + /** + * Retrieves available shipping options for a cart using Medusa Store API. + * Uses `sdk.store.fulfillment.listCartOptions()` to get shipping options scoped to the cart's region. + * + * For shipping options with `price_type=calculated`, calculates the price using + * `sdk.store.fulfillment.calculate()` before returning. Flat-price options are returned as-is. + * + * @see https://docs.medusajs.com/resources/storefront-development/checkout/shipping + */ + getShippingOptions( + params: Checkout.Request.GetShippingOptionsParams, + authorization?: string, + ): Observable { + const headers = this.medusaJsService.getStoreApiHeaders(authorization); + + // Step 1: Retrieve shipping options + return from(this.sdk.store.fulfillment.listCartOptions({ cart_id: params.cartId }, headers)).pipe( + switchMap((response: HttpTypes.StoreShippingOptionListResponse) => { + const shippingOptions = response.shipping_options; + + // Step 2: Filter options that need price calculation + const calculatedOptions = shippingOptions.filter((option) => option.price_type === 'calculated'); + + if (calculatedOptions.length === 0) { + return of(mapShippingOptions(shippingOptions, this.defaultCurrency)); + } + + // Step 3: Calculate prices for all calculated options in parallel + const calculateObservables = calculatedOptions.map((option) => + from( + this.sdk.store.fulfillment.calculate(option.id, { cart_id: params.cartId, data: {} }, headers), + ).pipe( + map((calcResponse: { shipping_option: HttpTypes.StoreCartShippingOption }) => ({ + optionId: option.id, + calculatedOption: calcResponse.shipping_option, + })), + catchError((error) => { + this.logger.warn(`Failed to calculate price for shipping option ${option.id}`, error); + return of({ optionId: option.id, calculatedOption: undefined }); + }), + ), + ); + + // Step 4: Wait for all calculations, then merge results + return forkJoin(calculateObservables).pipe( + map((calculatedResults) => { + const calculatedMap = new Map(); + calculatedResults.forEach((result) => { + if (result.calculatedOption) { + calculatedMap.set(result.optionId, result.calculatedOption); + } + }); + + const enrichedOptions = shippingOptions.map((option) => + option.price_type === 'calculated' && calculatedMap.has(option.id) + ? calculatedMap.get(option.id)! + : option, + ); + + return mapShippingOptions(enrichedOptions, this.defaultCurrency); + }), + ); + }), + catchError((error) => handleHttpError(error)), + ); + } + + completeCheckout( + params: Checkout.Request.CompleteCheckoutParams, + data: Checkout.Request.CompleteCheckoutBody, + authorization: string | undefined, + ): Observable { + // Setup addresses first + return this.setAddresses( + { cartId: params.cartId }, + { + shippingAddressId: data.shippingAddressId, + shippingAddress: data.shippingAddress, + billingAddressId: data.billingAddressId, + billingAddress: data.billingAddress, + notes: data.notes, + email: data.email, + }, + authorization, + ).pipe( + // Setup shipping method if provided + switchMap(() => + data.shippingMethodId + ? this.setShippingMethod( + { cartId: params.cartId }, + { shippingOptionId: data.shippingMethodId }, + authorization, + ) + : throwError(() => new BadRequestException('Shipping method is required for checkout')), + ), + switchMap(() => + // Setup payment + data.paymentProviderId + ? this.setPayment( + { cartId: params.cartId }, + { + providerId: data.paymentProviderId, + returnUrl: data.returnUrl, + cancelUrl: data.cancelUrl, + metadata: data.metadata, + }, + authorization, + ) + : throwError(() => new BadRequestException('Payment provider is required for checkout')), + ), + switchMap(() => + // Place order + this.placeOrder( + { cartId: params.cartId }, + { + email: data.email, + }, + authorization, + ), + ), + ); + } +} diff --git a/packages/integrations/medusajs/src/modules/checkout/index.ts b/packages/integrations/medusajs/src/modules/checkout/index.ts new file mode 100644 index 000000000..995a0d3f8 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/checkout/index.ts @@ -0,0 +1,7 @@ +import { Checkout } from '@o2s/framework/modules'; + +export { CheckoutService as Service } from './checkout.service'; +export * as Mapper from './checkout.mapper'; + +export import Request = Checkout.Request; +export import Model = Checkout.Model; diff --git a/packages/integrations/medusajs/src/modules/customers/customers.mapper.spec.ts b/packages/integrations/medusajs/src/modules/customers/customers.mapper.spec.ts new file mode 100644 index 000000000..bf16ce84d --- /dev/null +++ b/packages/integrations/medusajs/src/modules/customers/customers.mapper.spec.ts @@ -0,0 +1,115 @@ +import { HttpTypes } from '@medusajs/types'; +import { describe, expect, it } from 'vitest'; + +import { mapAddressToMedusa, mapCustomerAddress, mapCustomerAddresses } from './customers.mapper'; + +function minimalMedusaAddress(overrides: Record = {}): HttpTypes.StoreCustomerAddress { + return { + id: 'addr_1', + first_name: 'John', + last_name: 'Doe', + country_code: 'pl', + address_1: 'Street', + address_2: '1', + city: 'Warsaw', + postal_code: '00-001', + province: 'Mazovia', + phone: '+48', + is_default_shipping: true, + is_default_billing: false, + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-02'), + ...overrides, + } as unknown as HttpTypes.StoreCustomerAddress; +} + +describe('customers.mapper', () => { + describe('mapCustomerAddress', () => { + it('should map id, customerId, label, isDefault and address fields', () => { + const medusa = minimalMedusaAddress(); + const result = mapCustomerAddress(medusa, 'cust_1'); + expect(result.id).toBe('addr_1'); + expect(result.customerId).toBe('cust_1'); + expect(result.label).toBe('John Doe'); + expect(result.isDefault).toBe(true); + expect(result.address.firstName).toBe('John'); + expect(result.address.lastName).toBe('Doe'); + expect(result.address.country).toBe('pl'); + expect(result.address.streetName).toBe('Street'); + expect(result.address.streetNumber).toBe('1'); + expect(result.address.city).toBe('Warsaw'); + expect(result.address.postalCode).toBe('00-001'); + expect(result.address.region).toBe('Mazovia'); + expect(result.address.phone).toBe('+48'); + expect(result.createdAt).toBeDefined(); + expect(result.updatedAt).toBeDefined(); + expect(String(result.createdAt)).toContain('2024'); + expect(String(result.updatedAt)).toContain('2024'); + }); + + it('should set label undefined when first_name is empty', () => { + const medusa = minimalMedusaAddress({ first_name: '', last_name: '' }); + const result = mapCustomerAddress(medusa, 'cust_1'); + expect(result.label).toBeUndefined(); + }); + }); + + describe('mapCustomerAddresses', () => { + it('should paginate with offset and limit and return total', () => { + const addresses = [ + minimalMedusaAddress({ id: 'addr_1' }), + minimalMedusaAddress({ id: 'addr_2' }), + minimalMedusaAddress({ id: 'addr_3' }), + ]; + const result = mapCustomerAddresses(addresses, 'cust_1', 2, 1); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(3); + expect(result.data[0]?.id).toBe('addr_2'); + expect(result.data[1]?.id).toBe('addr_3'); + }); + }); + + describe('mapAddressToMedusa', () => { + it('should map O2S address to StoreCreateCustomerAddress with country_code lowercase', () => { + const address = { + firstName: 'Jane', + lastName: 'Doe', + country: 'PL', + streetName: 'Main St', + streetNumber: '10', + apartment: '', + city: 'Krakow', + postalCode: '30-001', + region: 'Lesser Poland', + phone: '+48123456789', + }; + const result = mapAddressToMedusa(address as never); + expect(result.first_name).toBe('Jane'); + expect(result.last_name).toBe('Doe'); + expect(result.country_code).toBe('pl'); + expect(result.address_1).toBe('Main St'); + expect(result.address_2).toBe('10'); // streetNumber takes precedence over apartment + expect(result.city).toBe('Krakow'); + expect(result.postal_code).toBe('30-001'); + expect(result.province).toBe('Lesser Poland'); + expect(result.phone).toBe('+48123456789'); + }); + + it('should use apartment when streetNumber is empty for address_2', () => { + const address = { + firstName: 'J', + lastName: 'D', + country: 'pl', + streetName: 'S', + streetNumber: '', + apartment: 'Apt 2', + city: 'C', + postalCode: '', + region: undefined, + phone: undefined, + }; + const result = mapAddressToMedusa(address as never); + expect(result.address_2).toBe('Apt 2'); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts new file mode 100644 index 000000000..fb0889986 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts @@ -0,0 +1,56 @@ +import { HttpTypes } from '@medusajs/types'; + +import { Customers, Models } from '@o2s/framework/modules'; + +export function mapCustomerAddress( + medusaAddress: HttpTypes.StoreCustomerAddress, + customerId: string, +): Customers.Model.CustomerAddress { + return { + id: medusaAddress.id, + customerId, + label: medusaAddress.first_name ? `${medusaAddress.first_name} ${medusaAddress.last_name}` : undefined, + isDefault: medusaAddress.is_default_shipping || medusaAddress.is_default_billing, + address: { + firstName: medusaAddress.first_name ?? undefined, + lastName: medusaAddress.last_name ?? undefined, + country: medusaAddress.country_code || '', + streetName: medusaAddress.address_1 || '', + streetNumber: medusaAddress.address_2 ?? undefined, + city: medusaAddress.city || '', + postalCode: medusaAddress.postal_code || '', + region: medusaAddress.province ?? undefined, + phone: medusaAddress.phone ?? undefined, + }, + createdAt: medusaAddress.created_at || new Date().toISOString(), + updatedAt: medusaAddress.updated_at || new Date().toISOString(), + }; +} + +export function mapCustomerAddresses( + medusaAddresses: HttpTypes.StoreCustomerAddress[], + customerId: string, + limit = 10, + offset = 0, +): Customers.Model.CustomerAddresses { + const addresses = medusaAddresses.map((addr) => mapCustomerAddress(addr, customerId)); + + return { + data: addresses.slice(offset, offset + limit), + total: addresses.length, + }; +} + +export function mapAddressToMedusa(address: Models.Address.Address): HttpTypes.StoreCreateCustomerAddress { + return { + first_name: (address.firstName || '').trim(), + last_name: (address.lastName || '').trim(), + address_1: (address.streetName || '').trim(), + address_2: (address.streetNumber || address.apartment || '').trim() || undefined, + city: (address.city || '').trim(), + country_code: (address.country || '').toLowerCase().trim(), // Medusa.js requires lowercase ISO country codes + postal_code: (address.postalCode || '').trim(), + province: address.region?.trim() || undefined, + phone: address.phone?.trim() || undefined, + }; +} diff --git a/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts new file mode 100644 index 000000000..a078635e1 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts @@ -0,0 +1,564 @@ +import { HttpTypes } from '@medusajs/types'; +import { InternalServerErrorException, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Auth, Customers } from '@o2s/framework/modules'; + +import { CustomersService } from './customers.service'; + +const minimalAddress = { + id: 'addr_1', + first_name: 'John', + last_name: 'Doe', + country_code: 'pl', + address_1: 'Street', + address_2: '1', + city: 'Warsaw', + postal_code: '00-001', + province: 'Mazovia', + phone: '+48', + is_default_shipping: true, + is_default_billing: false, + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-02'), +}; + +describe('CustomersService', () => { + let service: CustomersService; + let mockSdk: { + store: { + customer: { + listAddress: ReturnType; + retrieveAddress: ReturnType; + createAddress: ReturnType; + updateAddress: ReturnType; + deleteAddress: ReturnType; + }; + }; + }; + let mockMedusaJsService: { getSdk: ReturnType; getStoreApiHeaders: ReturnType }; + let mockAuthService: { getCustomerId: ReturnType }; + let mockLogger: { debug: ReturnType }; + + beforeEach(() => { + vi.restoreAllMocks(); + mockSdk = { + store: { + customer: { + listAddress: vi.fn(), + retrieveAddress: vi.fn(), + createAddress: vi.fn(), + updateAddress: vi.fn(), + deleteAddress: vi.fn(), + }, + }, + }; + mockMedusaJsService = { + getSdk: vi.fn(() => mockSdk), + getStoreApiHeaders: vi.fn(() => ({})), + }; + mockAuthService = { getCustomerId: vi.fn() }; + mockLogger = { debug: vi.fn() }; + + service = new CustomersService( + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + mockAuthService as unknown as Auth.Service, + ); + }); + + describe('getAddresses', () => { + it('should throw UnauthorizedException when auth is missing', async () => { + await expect(firstValueFrom(service.getAddresses(undefined))).rejects.toThrow(UnauthorizedException); + await expect(firstValueFrom(service.getAddresses(undefined))).rejects.toThrow('Authentication required'); + }); + + it('should throw UnauthorizedException when getCustomerId returns undefined', async () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + await expect(firstValueFrom(service.getAddresses('Bearer token'))).rejects.toThrow(UnauthorizedException); + await expect(firstValueFrom(service.getAddresses('Bearer token'))).rejects.toThrow( + 'Invalid authentication', + ); + }); + + it('should call listAddress and return mapCustomerAddresses', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.listAddress.mockResolvedValue({ + addresses: [minimalAddress], + } as unknown as HttpTypes.StoreCustomerAddressListResponse); + + const result = await firstValueFrom(service.getAddresses('Bearer token')); + + expect(mockSdk.store.customer.listAddress).toHaveBeenCalledWith({}, expect.any(Object)); + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe('addr_1'); + expect(result.total).toBe(1); + }); + + it('should throw UnauthorizedException on 401 or 403', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.listAddress.mockRejectedValue({ response: { status: 401 } }); + + await expect(firstValueFrom(service.getAddresses('Bearer token'))).rejects.toThrow(UnauthorizedException); + + mockSdk.store.customer.listAddress.mockRejectedValue({ response: { status: 403 } }); + await expect(firstValueFrom(service.getAddresses('Bearer token'))).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('getAddress', () => { + it('should throw UnauthorizedException when auth is missing', async () => { + await expect( + firstValueFrom(service.getAddress({ id: 'addr_1' } as Customers.Request.GetAddressParams, undefined)), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom(service.getAddress({ id: 'addr_1' } as Customers.Request.GetAddressParams, undefined)), + ).rejects.toThrow('Authentication required'); + }); + + it('should throw UnauthorizedException when getCustomerId returns undefined', async () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + await expect( + firstValueFrom( + service.getAddress({ id: 'addr_1' } as Customers.Request.GetAddressParams, 'Bearer token'), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom( + service.getAddress({ id: 'addr_1' } as Customers.Request.GetAddressParams, 'Bearer token'), + ), + ).rejects.toThrow('Invalid authentication'); + }); + + it('should call retrieveAddress and return mapped address', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.retrieveAddress.mockResolvedValue({ + address: minimalAddress, + } as unknown as HttpTypes.StoreCustomerAddressResponse); + + const result = await firstValueFrom( + service.getAddress({ id: 'addr_1' } as Customers.Request.GetAddressParams, 'Bearer token'), + ); + + expect(mockSdk.store.customer.retrieveAddress).toHaveBeenCalledWith('addr_1', {}, expect.any(Object)); + expect(result).toBeDefined(); + expect(result?.id).toBe('addr_1'); + }); + + it('should return of(undefined) on 404', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.retrieveAddress.mockRejectedValue({ response: { status: 404 } }); + + const result = await firstValueFrom( + service.getAddress({ id: 'missing' } as Customers.Request.GetAddressParams, 'Bearer token'), + ); + + expect(result).toBeUndefined(); + }); + }); + + describe('createAddress', () => { + it('should throw UnauthorizedException when auth is missing', async () => { + await expect( + firstValueFrom( + service.createAddress({ address: {} } as Customers.Request.CreateAddressBody, undefined), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom( + service.createAddress({ address: {} } as Customers.Request.CreateAddressBody, undefined), + ), + ).rejects.toThrow('Authentication required'); + }); + + it('should throw UnauthorizedException when getCustomerId returns undefined', async () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + await expect( + firstValueFrom( + service.createAddress({ address: {} } as Customers.Request.CreateAddressBody, 'Bearer token'), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom( + service.createAddress({ address: {} } as Customers.Request.CreateAddressBody, 'Bearer token'), + ), + ).rejects.toThrow('Invalid authentication'); + }); + + it('should call createAddress SDK and return mapped address', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + const createdAddress = { + id: 'addr_new', + first_name: 'John', + last_name: 'Doe', + country_code: 'pl', + city: 'Warsaw', + address_1: '', + address_2: undefined, + postal_code: '', + province: undefined, + phone: undefined, + is_default_shipping: false, + is_default_billing: false, + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-02'), + }; + mockSdk.store.customer.createAddress.mockResolvedValue({ + customer: { addresses: [createdAddress] }, + } as unknown as HttpTypes.StoreCustomerResponse); + + const result = await firstValueFrom( + service.createAddress( + { + address: { + firstName: 'John', + lastName: 'Doe', + country: 'PL', + city: 'Warsaw', + }, + } as Customers.Request.CreateAddressBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.customer.createAddress).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should call setDefaultAddress when data.isDefault is true', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + const createdAddress = { + id: 'addr_new', + first_name: 'John', + last_name: 'Doe', + country_code: 'pl', + city: 'Warsaw', + address_1: '', + address_2: undefined, + postal_code: '', + province: undefined, + phone: undefined, + is_default_shipping: false, + is_default_billing: false, + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-02'), + }; + mockSdk.store.customer.createAddress.mockResolvedValue({ + customer: { addresses: [createdAddress] }, + } as unknown as HttpTypes.StoreCustomerResponse); + mockSdk.store.customer.updateAddress.mockResolvedValue({ + customer: { + addresses: [{ ...createdAddress, is_default_shipping: true, is_default_billing: true }], + }, + } as unknown as HttpTypes.StoreCustomerResponse); + + const result = await firstValueFrom( + service.createAddress( + { + address: { firstName: 'John', lastName: 'Doe', country: 'PL', city: 'Warsaw' }, + isDefault: true, + } as Customers.Request.CreateAddressBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.customer.updateAddress).toHaveBeenCalledWith( + 'addr_new', + expect.objectContaining({ is_default_shipping: true, is_default_billing: true }), + {}, + expect.any(Object), + ); + expect(result).toBeDefined(); + }); + + it('should throw InternalServerErrorException when created address is not found in response', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.createAddress.mockResolvedValue({ + customer: { addresses: [] }, + } as unknown as HttpTypes.StoreCustomerResponse); + + await expect( + firstValueFrom( + service.createAddress( + { + address: { + firstName: 'John', + lastName: 'Doe', + country: 'PL', + city: 'Warsaw', + }, + } as Customers.Request.CreateAddressBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(InternalServerErrorException); + await expect( + firstValueFrom( + service.createAddress( + { + address: { + firstName: 'John', + lastName: 'Doe', + country: 'PL', + city: 'Warsaw', + }, + } as Customers.Request.CreateAddressBody, + 'Bearer token', + ), + ), + ).rejects.toThrow('Failed to create address or find created address in response'); + }); + }); + + describe('updateAddress', () => { + it('should throw UnauthorizedException when auth is missing', async () => { + await expect( + firstValueFrom( + service.updateAddress({ id: 'addr_1' }, {} as Customers.Request.UpdateAddressBody, undefined), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom( + service.updateAddress({ id: 'addr_1' }, {} as Customers.Request.UpdateAddressBody, undefined), + ), + ).rejects.toThrow('Authentication required'); + }); + + it('should throw UnauthorizedException when getCustomerId returns undefined', async () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + await expect( + firstValueFrom( + service.updateAddress({ id: 'addr_1' }, {} as Customers.Request.UpdateAddressBody, 'Bearer token'), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom( + service.updateAddress({ id: 'addr_1' }, {} as Customers.Request.UpdateAddressBody, 'Bearer token'), + ), + ).rejects.toThrow('Invalid authentication'); + }); + + it('should call updateAddress SDK and return mapped address', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.updateAddress.mockResolvedValue({ + customer: { addresses: [{ ...minimalAddress, id: 'addr_1' }] }, + } as unknown as HttpTypes.StoreCustomerResponse); + + const result = await firstValueFrom( + service.updateAddress( + { id: 'addr_1' }, + { address: { firstName: 'Jane' } } as Customers.Request.UpdateAddressBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.customer.updateAddress).toHaveBeenCalledWith( + 'addr_1', + expect.any(Object), + {}, + expect.any(Object), + ); + expect(result).toBeDefined(); + }); + + it('should throw NotFoundException when updated address is not found in response', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.updateAddress.mockResolvedValue({ + customer: { addresses: [] }, + } as unknown as HttpTypes.StoreCustomerResponse); + + await expect( + firstValueFrom( + service.updateAddress( + { id: 'addr_missing' }, + { address: { firstName: 'Jane' } } as Customers.Request.UpdateAddressBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(NotFoundException); + await expect( + firstValueFrom( + service.updateAddress( + { id: 'addr_missing' }, + { address: { firstName: 'Jane' } } as Customers.Request.UpdateAddressBody, + 'Bearer token', + ), + ), + ).rejects.toThrow('Address with ID addr_missing not found'); + }); + + it('should throw NotFoundException on 404', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.updateAddress.mockRejectedValue({ response: { status: 404 } }); + + await expect( + firstValueFrom( + service.updateAddress( + { id: 'addr_missing' }, + {} as Customers.Request.UpdateAddressBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('deleteAddress', () => { + it('should throw UnauthorizedException when auth is missing', async () => { + await expect( + firstValueFrom( + service.deleteAddress({ id: 'addr_1' } as Customers.Request.DeleteAddressParams, undefined), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom( + service.deleteAddress({ id: 'addr_1' } as Customers.Request.DeleteAddressParams, undefined), + ), + ).rejects.toThrow('Authentication required'); + }); + + it('should throw UnauthorizedException when getCustomerId returns undefined', async () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + await expect( + firstValueFrom( + service.deleteAddress({ id: 'addr_1' } as Customers.Request.DeleteAddressParams, 'Bearer token'), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom( + service.deleteAddress({ id: 'addr_1' } as Customers.Request.DeleteAddressParams, 'Bearer token'), + ), + ).rejects.toThrow('Invalid authentication'); + }); + + it('should call deleteAddress SDK', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.deleteAddress.mockResolvedValue({}); + + await firstValueFrom( + service.deleteAddress({ id: 'addr_1' } as Customers.Request.DeleteAddressParams, 'Bearer token'), + ); + + expect(mockSdk.store.customer.deleteAddress).toHaveBeenCalledWith('addr_1', expect.any(Object)); + }); + + it('should throw NotFoundException on 404', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.deleteAddress.mockRejectedValue({ response: { status: 404 } }); + + await expect( + firstValueFrom( + service.deleteAddress( + { id: 'addr_missing' } as Customers.Request.DeleteAddressParams, + 'Bearer token', + ), + ), + ).rejects.toThrow(NotFoundException); + await expect( + firstValueFrom( + service.deleteAddress( + { id: 'addr_missing' } as Customers.Request.DeleteAddressParams, + 'Bearer token', + ), + ), + ).rejects.toThrow('Address with ID addr_missing not found'); + }); + }); + + describe('setDefaultAddress', () => { + it('should throw UnauthorizedException when auth is missing', async () => { + await expect( + firstValueFrom( + service.setDefaultAddress({ id: 'addr_1' } as Customers.Request.SetDefaultAddressParams, undefined), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom( + service.setDefaultAddress({ id: 'addr_1' } as Customers.Request.SetDefaultAddressParams, undefined), + ), + ).rejects.toThrow('Authentication required'); + }); + + it('should throw UnauthorizedException when getCustomerId returns undefined', async () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + await expect( + firstValueFrom( + service.setDefaultAddress( + { id: 'addr_1' } as Customers.Request.SetDefaultAddressParams, + 'Bearer token', + ), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + firstValueFrom( + service.setDefaultAddress( + { id: 'addr_1' } as Customers.Request.SetDefaultAddressParams, + 'Bearer token', + ), + ), + ).rejects.toThrow('Invalid authentication'); + }); + + it('should call updateAddress with is_default_shipping and return mapped address', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.updateAddress.mockResolvedValue({ + customer: { addresses: [{ ...minimalAddress, is_default_shipping: true }] }, + } as unknown as HttpTypes.StoreCustomerResponse); + + const result = await firstValueFrom( + service.setDefaultAddress( + { id: 'addr_1' } as Customers.Request.SetDefaultAddressParams, + 'Bearer token', + ), + ); + + expect(mockSdk.store.customer.updateAddress).toHaveBeenCalledWith( + 'addr_1', + { is_default_shipping: true, is_default_billing: true }, + {}, + expect.any(Object), + ); + expect(result).toBeDefined(); + expect(result?.id).toBe('addr_1'); + }); + + it('should throw NotFoundException when address is not found in response', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.updateAddress.mockResolvedValue({ + customer: { addresses: [] }, + } as unknown as HttpTypes.StoreCustomerResponse); + + await expect( + firstValueFrom( + service.setDefaultAddress( + { id: 'addr_missing' } as Customers.Request.SetDefaultAddressParams, + 'Bearer token', + ), + ), + ).rejects.toThrow(NotFoundException); + await expect( + firstValueFrom( + service.setDefaultAddress( + { id: 'addr_missing' } as Customers.Request.SetDefaultAddressParams, + 'Bearer token', + ), + ), + ).rejects.toThrow('Address with ID addr_missing not found'); + }); + + it('should throw NotFoundException on 404', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.updateAddress.mockRejectedValue({ response: { status: 404 } }); + + await expect( + firstValueFrom( + service.setDefaultAddress( + { id: 'addr_missing' } as Customers.Request.SetDefaultAddressParams, + 'Bearer token', + ), + ), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/customers/customers.service.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.ts new file mode 100644 index 000000000..ab685101a --- /dev/null +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.ts @@ -0,0 +1,293 @@ +import Medusa from '@medusajs/js-sdk'; +import { HttpTypes } from '@medusajs/types'; +import { + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { Observable, catchError, from, map, of, switchMap, throwError } from 'rxjs'; + +import { LoggerService } from '@o2s/utils.logger'; + +import { Auth, Customers } from '@o2s/framework/modules'; + +import { Service as MedusaJsService } from '@/modules/medusajs'; + +import { handleHttpError } from '../../utils/handle-http-error'; + +import { mapAddressToMedusa, mapCustomerAddress, mapCustomerAddresses } from './customers.mapper'; + +/** + * Medusa.js implementation of the Customers service. + * + * Uses Medusa Store API for all customer address operations. + * Requires a custom Medusa auth plugin to validate SSO tokens passed via the authorization header. + */ +@Injectable() +export class CustomersService extends Customers.Service { + private readonly sdk: Medusa; + + constructor( + @Inject(LoggerService) protected readonly logger: LoggerService, + private readonly medusaJsService: MedusaJsService, + private readonly authService: Auth.Service, + ) { + super(); + this.sdk = this.medusaJsService.getSdk(); + } + + getAddresses(authorization: string | undefined): Observable { + if (!authorization) { + return throwError(() => new UnauthorizedException('Authentication required')); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + return throwError(() => new UnauthorizedException('Invalid authentication')); + } + + return from( + this.sdk.store.customer.listAddress({}, this.medusaJsService.getStoreApiHeaders(authorization)), + ).pipe( + map((response: HttpTypes.StoreCustomerAddressListResponse) => { + const addresses = response.addresses || []; + return mapCustomerAddresses(addresses, customerId); + }), + catchError((error) => { + if (error.response?.status === 401 || error.response?.status === 403) { + return throwError(() => new UnauthorizedException('Unauthorized to access addresses')); + } + return handleHttpError(error); + }), + ); + } + + getAddress( + params: Customers.Request.GetAddressParams, + authorization: string | undefined, + ): Observable { + if (!authorization) { + return throwError(() => new UnauthorizedException('Authentication required')); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + return throwError(() => new UnauthorizedException('Invalid authentication')); + } + + return from( + this.sdk.store.customer.retrieveAddress( + params.id, + {}, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StoreCustomerAddressResponse) => { + return mapCustomerAddress(response.address, customerId); + }), + catchError((error) => { + if (error.response?.status === 404) { + return of(undefined); + } + if (error.response?.status === 401 || error.response?.status === 403) { + return throwError(() => new UnauthorizedException('Unauthorized to access address')); + } + return handleHttpError(error); + }), + ); + } + + createAddress( + data: Customers.Request.CreateAddressBody, + authorization: string | undefined, + ): Observable { + if (!authorization) { + return throwError(() => new UnauthorizedException('Authentication required')); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + return throwError(() => new UnauthorizedException('Invalid authentication')); + } + + const medusaAddress = mapAddressToMedusa(data.address); + + return from( + this.sdk.store.customer.createAddress( + medusaAddress, + {}, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + switchMap((response: HttpTypes.StoreCustomerResponse) => { + const customer = response.customer; + const addresses = customer.addresses || []; + + // Find the created address by comparing fields with what we sent + const createdAddress = addresses.find((addr) => { + return ( + addr.first_name === medusaAddress.first_name && + addr.last_name === medusaAddress.last_name && + addr.address_1 === medusaAddress.address_1 && + addr.city === medusaAddress.city && + addr.postal_code === medusaAddress.postal_code && + addr.country_code === medusaAddress.country_code && + (addr.address_2 || '') === (medusaAddress.address_2 || '') && + (addr.province || '') === (medusaAddress.province || '') && + (addr.phone || '') === (medusaAddress.phone || '') + ); + }); + + if (!createdAddress) { + return throwError( + () => + new InternalServerErrorException( + 'Failed to create address or find created address in response', + ), + ); + } + + const address = mapCustomerAddress(createdAddress, customerId); + + // If this should be default, set it as default + if (data.isDefault) { + return this.setDefaultAddress({ id: address.id }, authorization); + } + + return of(address); + }), + catchError((error) => { + if (error.response?.status === 401 || error.response?.status === 403) { + return throwError(() => new UnauthorizedException('Unauthorized to create address')); + } + return handleHttpError(error); + }), + ); + } + + updateAddress( + params: Customers.Request.UpdateAddressParams, + data: Customers.Request.UpdateAddressBody, + authorization: string | undefined, + ): Observable { + if (!authorization) { + return throwError(() => new UnauthorizedException('Authentication required')); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + return throwError(() => new UnauthorizedException('Invalid authentication')); + } + + const updateData = data.address ? mapAddressToMedusa(data.address) : {}; + + return from( + this.sdk.store.customer.updateAddress( + params.id, + updateData, + {}, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + switchMap((response: HttpTypes.StoreCustomerResponse) => { + const customer = response.customer; + const addresses = customer.addresses || []; + const updatedAddress = addresses.find((a) => a.id === params.id); + if (!updatedAddress) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + + const address = mapCustomerAddress(updatedAddress, customerId); + + // If setting as default, update that + if (data.isDefault !== undefined && data.isDefault) { + return this.setDefaultAddress({ id: address.id }, authorization); + } + + return of(address); + }), + catchError((error) => { + if (error.response?.status === 404) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + if (error.response?.status === 401 || error.response?.status === 403) { + return throwError(() => new UnauthorizedException('Unauthorized to update address')); + } + return handleHttpError(error); + }), + ); + } + + deleteAddress(params: Customers.Request.DeleteAddressParams, authorization: string | undefined): Observable { + if (!authorization) { + return throwError(() => new UnauthorizedException('Authentication required')); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + return throwError(() => new UnauthorizedException('Invalid authentication')); + } + + return from( + this.sdk.store.customer.deleteAddress(params.id, this.medusaJsService.getStoreApiHeaders(authorization)), + ).pipe( + map((_response: HttpTypes.StoreCustomerAddressDeleteResponse) => undefined), + catchError((error) => { + if (error.response?.status === 404) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + if (error.response?.status === 401 || error.response?.status === 403) { + return throwError(() => new UnauthorizedException('Unauthorized to delete address')); + } + return handleHttpError(error); + }), + ); + } + + setDefaultAddress( + params: Customers.Request.SetDefaultAddressParams, + authorization: string | undefined, + ): Observable { + if (!authorization) { + return throwError(() => new UnauthorizedException('Authentication required')); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + return throwError(() => new UnauthorizedException('Invalid authentication')); + } + + return from( + this.sdk.store.customer.updateAddress( + params.id, + { + is_default_shipping: true, + is_default_billing: true, + } as Partial, + {}, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + switchMap((response: HttpTypes.StoreCustomerResponse) => { + const customer = response.customer; + const addresses = customer.addresses || []; + const updatedAddress = addresses.find((a) => a.id === params.id); + if (!updatedAddress) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + return of(mapCustomerAddress(updatedAddress, customerId)); + }), + catchError((error) => { + if (error.response?.status === 404) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + if (error.response?.status === 401 || error.response?.status === 403) { + return throwError(() => new UnauthorizedException('Unauthorized to set default address')); + } + return handleHttpError(error); + }), + ); + } +} diff --git a/packages/integrations/medusajs/src/modules/customers/index.ts b/packages/integrations/medusajs/src/modules/customers/index.ts new file mode 100644 index 000000000..5880ed19c --- /dev/null +++ b/packages/integrations/medusajs/src/modules/customers/index.ts @@ -0,0 +1,7 @@ +import { Customers } from '@o2s/framework/modules'; + +export { CustomersService as Service } from './customers.service'; +export * as Mapper from './customers.mapper'; + +export import Request = Customers.Request; +export import Model = Customers.Model; diff --git a/packages/integrations/medusajs/src/modules/index.ts b/packages/integrations/medusajs/src/modules/index.ts index be7a1e06c..0628cda51 100644 --- a/packages/integrations/medusajs/src/modules/index.ts +++ b/packages/integrations/medusajs/src/modules/index.ts @@ -1,4 +1,8 @@ export * as Orders from './orders'; export * as Products from './products'; export * as Resources from './resources'; +export * as Carts from './carts'; +export * as Customers from './customers'; +export * as Payments from './payments'; +export * as Checkout from './checkout'; export * as MedusaJs from './medusajs'; diff --git a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.spec.ts b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.spec.ts index 9b71ce8dc..83d775f6e 100644 --- a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.spec.ts @@ -31,8 +31,8 @@ describe('MedusaJsService', () => { }; }); - describe('lazy initialization', () => { - it('should throw when MEDUSAJS_BASE_URL is not defined', () => { + describe('constructor and initialization', () => { + it('should throw when MEDUSAJS_BASE_URL is not defined (on first getSdk call)', () => { vi.mocked(mockConfig.get).mockImplementation((key: string) => key === 'MEDUSAJS_BASE_URL' ? '' : defaultConfig(key), ); @@ -41,7 +41,7 @@ describe('MedusaJsService', () => { expect(() => service.getSdk()).toThrow('MEDUSAJS_BASE_URL is not defined'); }); - it('should throw when MEDUSAJS_PUBLISHABLE_API_KEY is not defined', () => { + it('should throw when MEDUSAJS_PUBLISHABLE_API_KEY is not defined (on first getSdk call)', () => { vi.mocked(mockConfig.get).mockImplementation((key: string) => key === 'MEDUSAJS_PUBLISHABLE_API_KEY' ? '' : defaultConfig(key), ); @@ -50,7 +50,7 @@ describe('MedusaJsService', () => { expect(() => service.getSdk()).toThrow('MEDUSAJS_PUBLISHABLE_API_KEY is not defined'); }); - it('should throw when MEDUSAJS_ADMIN_API_KEY is not defined', () => { + it('should throw when MEDUSAJS_ADMIN_API_KEY is not defined (on first getSdk call)', () => { vi.mocked(mockConfig.get).mockImplementation((key: string) => key === 'MEDUSAJS_ADMIN_API_KEY' ? '' : defaultConfig(key), ); @@ -59,16 +59,15 @@ describe('MedusaJsService', () => { expect(() => service.getSdk()).toThrow('MEDUSAJS_ADMIN_API_KEY is not defined'); }); - it('should create Medusa SDK with config and debug false when LOG_LEVEL is not debug', () => { - const service = new MedusaJsService(mockConfig as unknown as ConfigService); - service.getSdk(); - - expect(Medusa).toHaveBeenCalledWith({ - baseUrl: 'https://api.medusa.test', - debug: false, - publishableKey: 'pk_test', - apiKey: 'admin_secret', - }); + it('should create Medusa SDK when getSdk is called', () => { + new MedusaJsService(mockConfig as unknown as ConfigService).getSdk(); + + expect(Medusa).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://api.medusa.test', + publishableKey: 'pk_test', + }), + ); }); it('should create Medusa SDK with debug true when LOG_LEVEL is debug', () => { @@ -76,8 +75,7 @@ describe('MedusaJsService', () => { key === 'LOG_LEVEL' ? 'debug' : defaultConfig(key), ); - const service = new MedusaJsService(mockConfig as unknown as ConfigService); - service.getSdk(); + new MedusaJsService(mockConfig as unknown as ConfigService).getSdk(); expect(Medusa).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts index 628ffa17e..329112199 100644 --- a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts +++ b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts @@ -1,7 +1,37 @@ import Medusa from '@medusajs/js-sdk'; -import { Global, Injectable } from '@nestjs/common'; +import { Global, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +/** + * Central service providing Medusa.js SDK access and common utilities. + * + * ## Authentication Architecture + * + * This integration uses two authentication strategies: + * + * 1. **Store API with SSO tokens** - For customer-facing operations (orders, carts, checkout, + * customers, payments, products). The authorization token (SSO JWT from Auth0/Keycloak/NextAuth) + * is passed directly to the Medusa Store API. A custom **Medusa auth plugin** must be deployed + * to validate these SSO tokens and map them to Medusa customer identities. + * + * 2. **Admin API with API key** - For operations without Store API equivalents (related products, + * custom resource endpoints). Uses `MEDUSAJS_ADMIN_API_KEY` for authentication. + * + * ## Required Environment Variables + * + * - `MEDUSAJS_BASE_URL` - Base URL of the Medusa server + * - `MEDUSAJS_PUBLISHABLE_API_KEY` - Publishable API key for Store API + * - `MEDUSAJS_ADMIN_API_KEY` - Admin API key (used only for Admin API operations) + * + * ## Integration Testing + * + * Integration tests should verify: + * - SSO token acceptance once the Medusa auth plugin is deployed + * - Store API operations return customer-scoped data + * - Admin API operations work for custom endpoints (related products, resources) + * + * @see {@link https://docs.medusajs.com/resources/js-sdk Medusa JS SDK} + */ @Global() @Injectable() export class MedusaJsService { @@ -26,13 +56,13 @@ export class MedusaJsService { this._medusaAdminApiKey = this.config.get('MEDUSAJS_ADMIN_API_KEY') || ''; if (!this._medusaBaseUrl) { - throw new Error('MEDUSAJS_BASE_URL is not defined'); + throw new InternalServerErrorException('MEDUSAJS_BASE_URL is not defined'); } if (!this._medusaPublishableApiKey) { - throw new Error('MEDUSAJS_PUBLISHABLE_API_KEY is not defined'); + throw new InternalServerErrorException('MEDUSAJS_PUBLISHABLE_API_KEY is not defined'); } if (!this._medusaAdminApiKey) { - throw new Error('MEDUSAJS_ADMIN_API_KEY is not defined'); + throw new InternalServerErrorException('MEDUSAJS_ADMIN_API_KEY is not defined'); } this._sdk = new Medusa({ @@ -40,6 +70,9 @@ export class MedusaJsService { debug: this.logLevel === 'debug', publishableKey: this._medusaPublishableApiKey, apiKey: this._medusaAdminApiKey, + auth: { + type: 'jwt', + }, }); this._medusaAdminApiKeyEncoded = Buffer.from(this._medusaAdminApiKey).toString('base64'); } @@ -69,10 +102,33 @@ export class MedusaJsService { return this._medusaAdminApiKeyEncoded!; } - getMedusaAdminApiHeaders() { + /** + * Returns headers for Admin API calls. + * Used only for operations without Store API equivalents (e.g., custom endpoints, related products). + */ + getMedusaAdminApiHeaders(): Record { return { 'x-publishable-api-key': this.getPublishableKey(), Authorization: `Basic ${this.getAdminKeyEncoded()}`, }; } + + /** + * Returns headers for authenticated Store API calls. + * + * The authorization token (SSO JWT) is passed as-is to the Medusa Store API. + * A custom Medusa auth plugin must be configured to validate external SSO tokens + * and map them to Medusa customer identities. + * + * @param authorization - Authorization header value from the API Harmonization layer (SSO JWT) + */ + getStoreApiHeaders(authorization?: string): Record { + const headers: Record = { + 'x-publishable-api-key': this.getPublishableKey(), + }; + if (authorization) { + headers['Authorization'] = authorization; + } + return headers; + } } diff --git a/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts b/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts index 5b756afc8..c94f8adba 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts @@ -5,7 +5,7 @@ import { mapOrder, mapOrders } from './orders.mapper'; const defaultCurrency = 'EUR'; -function minimalOrder(overrides: Record = {}): HttpTypes.AdminOrder { +function minimalOrder(overrides: Record = {}): HttpTypes.StoreOrder { return { id: 'order_1', customer_id: 'cust_1', @@ -24,7 +24,7 @@ function minimalOrder(overrides: Record = {}): HttpTypes.AdminO billing_address: null, shipping_methods: [], ...overrides, - } as unknown as HttpTypes.AdminOrder; + } as unknown as HttpTypes.StoreOrder; } describe('orders.mapper', () => { @@ -35,7 +35,7 @@ describe('orders.mapper', () => { count: 2, limit: 10, offset: 0, - } as HttpTypes.AdminOrderListResponse; + } as HttpTypes.StoreOrderListResponse; const result = mapOrders(response, defaultCurrency); expect(result.data).toHaveLength(2); expect(result.total).toBe(2); @@ -50,7 +50,7 @@ describe('orders.mapper', () => { const result = mapOrder(order, defaultCurrency); expect(result.id).toBe('order_1'); expect(result.customerId).toBe('cust_1'); - expect(result.currency).toBe('eur'); + expect(result.currency).toBe('EUR'); expect(result.status).toBe('COMPLETED'); expect(result.paymentStatus).toBe('CAPTURED'); expect(result.createdAt).toBe(order.created_at.toString()); diff --git a/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts b/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts index c2734382c..952d48d86 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts @@ -3,58 +3,62 @@ import { NotFoundException } from '@nestjs/common'; import { Models, Orders, Products } from '@o2s/framework/modules'; -export const mapOrders = (orders: HttpTypes.AdminOrderListResponse, defaultCurrency: string): Orders.Model.Orders => { +import { mapAddress } from '@/utils/address'; +import { parseCurrency } from '@/utils/currency'; +import { mapPrice } from '@/utils/price'; + +export const mapOrders = (orders: HttpTypes.StoreOrderListResponse, defaultCurrency: string): Orders.Model.Orders => { return { data: orders.orders.map((order) => mapOrder(order, defaultCurrency)), total: orders.count, }; }; -export const mapOrder = (order: HttpTypes.AdminOrder, defaultCurrency: string): Orders.Model.Order => { +export const mapOrder = (order: HttpTypes.StoreOrder, defaultCurrency: string): Orders.Model.Order => { + const currency = parseCurrency(order?.currency_code ?? defaultCurrency); + return { id: order.id, - total: mapPrice(order.total, order?.currency_code ?? defaultCurrency) as Models.Price.Price, - subtotal: mapPrice(order.subtotal, order?.currency_code ?? defaultCurrency), - shippingTotal: mapPrice(order.shipping_total, order?.currency_code ?? defaultCurrency), - discountTotal: mapPrice(order.discount_total, order?.currency_code ?? defaultCurrency), - tax: mapPrice(order.tax_total, order?.currency_code ?? defaultCurrency), - currency: (order?.currency_code as Models.Price.Currency) ?? (defaultCurrency as Models.Price.Currency), + total: mapPrice(order.total, currency, `Order ${order.id} total`), + subtotal: mapPrice(order.subtotal, currency, `Order ${order.id} subtotal`), + shippingTotal: mapPrice(order.shipping_total, currency, `Order ${order.id} shippingTotal`), + discountTotal: mapPrice(order.discount_total, currency, `Order ${order.id} discountTotal`), + tax: mapPrice(order.tax_total, currency, `Order ${order.id} tax`), + currency, paymentStatus: mapPaymentStatus(order.payment_status), status: mapStatus(order.status), customerId: order.customer_id || undefined, createdAt: order.created_at.toString(), updatedAt: order.updated_at.toString(), items: { - data: order?.items - ? order.items.map((item) => mapOrderItem(item, order?.currency_code ?? defaultCurrency)) - : [], + data: order?.items ? order.items.map((item) => mapOrderItem(item, currency)) : [], total: order?.items?.length ?? 0, }, shippingAddress: mapAddress(order.shipping_address), billingAddress: mapAddress(order.billing_address), shippingMethods: order.shipping_methods - ? order.shipping_methods.map((method) => mapShippingMethod(method, order?.currency_code ?? defaultCurrency)) + ? order.shipping_methods.map((method) => mapShippingMethod(method, currency)) : [], }; }; -const mapOrderItem = (item: HttpTypes.AdminOrderLineItem, currency: string): Orders.Model.OrderItem => { +const mapOrderItem = (item: HttpTypes.StoreOrderLineItem, currency: Models.Price.Currency): Orders.Model.OrderItem => { return { id: item.id, productId: item.variant_id || '', quantity: item.quantity, - price: mapPrice(item.unit_price, currency) as Models.Price.Price, - total: mapPrice(item.total, currency), - subtotal: mapPrice(item.subtotal, currency), - currency: currency as Models.Price.Currency, - product: mapProduct(item.unit_price, currency, item) as Products.Model.Product, + price: mapPrice(item.unit_price, currency, `Order item ${item.id} unit_price`), + total: mapPrice(item.total, currency, `Order item ${item.id} total`), + subtotal: mapPrice(item.subtotal, currency, `Order item ${item.id} subtotal`), + currency, + product: mapProduct(item.unit_price, currency, item), }; }; const mapProduct = ( unitPrice: number, - currency: string, - item?: HttpTypes.AdminOrderLineItem, + currency: Models.Price.Currency, + item?: HttpTypes.StoreOrderLineItem, ): Products.Model.Product => { if (!item) throw new NotFoundException('Product not found'); @@ -70,47 +74,24 @@ const mapProduct = ( alt: item.product_title || item.title, } : undefined, - price: mapPrice(unitPrice, currency) as Models.Price.Price, + price: mapPrice(unitPrice, currency, `Order product ${item.product_id} unit_price`), link: '', - type: 'PHYSICAL' as Products.Model.ProductType, + type: 'PHYSICAL', category: item.product?.categories?.[0]?.name || '', tags: [], }; }; -const mapAddress = (address?: HttpTypes.AdminOrderAddress | null): Models.Address.Address | undefined => { - if (!address) return undefined; - return { - country: address.country_code || '', - district: address.province || '', - region: address.province || '', - streetName: address.address_1 || '', - streetNumber: address.address_2 || '', - apartment: address.address_2 || '', - city: address.city || '', - postalCode: address.postal_code || '', - phone: address.phone || '', - }; -}; - const mapShippingMethod = ( - method: HttpTypes.AdminOrderShippingMethod, - currency: string, + method: HttpTypes.StoreOrderShippingMethod, + currency: Models.Price.Currency, ): Orders.Model.ShippingMethod => { return { id: method.id, name: method.name || '', description: method.description || '', - total: mapPrice(method.total, currency), - subtotal: mapPrice(method.subtotal, currency), - }; -}; - -const mapPrice = (value: number, currency: string): Models.Price.Price | undefined => { - if (typeof value === 'undefined') return undefined; - return { - value, - currency: currency as Models.Price.Currency, + total: mapPrice(method.total, currency, `Order shipping method ${method.id} total`), + subtotal: mapPrice(method.subtotal, currency, `Order shipping method ${method.id} subtotal`), }; }; diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts index 616fe250c..ee4ab254f 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts @@ -31,34 +31,34 @@ const minimalOrder = { describe('OrdersService', () => { let service: OrdersService; - let mockSdk: { admin: { order: { retrieve: ReturnType; list: ReturnType } } }; - let mockMedusaJsService: { getSdk: ReturnType }; + let mockSdk: { store: { order: { retrieve: ReturnType; list: ReturnType } } }; + let mockMedusaJsService: { getSdk: ReturnType; getStoreApiHeaders: ReturnType }; let mockAuthService: { getCustomerId: ReturnType }; let mockConfig: { get: ReturnType }; let mockLogger: { debug: ReturnType }; - let mockHttpClient: Record>; beforeEach(() => { vi.restoreAllMocks(); mockSdk = { - admin: { + store: { order: { retrieve: vi.fn(), list: vi.fn(), }, }, }; - mockMedusaJsService = { getSdk: vi.fn(() => mockSdk) }; + mockMedusaJsService = { + getSdk: vi.fn(() => mockSdk), + getStoreApiHeaders: vi.fn(() => ({})), + }; mockAuthService = { getCustomerId: vi.fn() }; mockConfig = { get: vi.fn((key: string) => (key === 'DEFAULT_CURRENCY' ? DEFAULT_CURRENCY : '')), }; mockLogger = { debug: vi.fn() }; - mockHttpClient = {}; service = new OrdersService( mockConfig as unknown as ConfigService, - mockHttpClient as unknown as import('@nestjs/axios').HttpService, mockLogger as unknown as import('@o2s/utils.logger').LoggerService, mockMedusaJsService as unknown as import('@/modules/medusajs').Service, mockAuthService as unknown as Auth.Service, @@ -73,7 +73,6 @@ describe('OrdersService', () => { () => new OrdersService( mockConfig as unknown as ConfigService, - mockHttpClient as unknown as import('@nestjs/axios').HttpService, mockLogger as unknown as import('@o2s/utils.logger').LoggerService, mockMedusaJsService as unknown as import('@/modules/medusajs').Service, mockAuthService as unknown as Auth.Service, @@ -88,14 +87,15 @@ describe('OrdersService', () => { expect(mockLogger.debug).toHaveBeenCalledWith('Authorization token not found'); }); - it('should call sdk.admin.order.retrieve and return mapped order', async () => { - mockSdk.admin.order.retrieve.mockResolvedValue({ order: minimalOrder }); + it('should call sdk.store.order.retrieve and return mapped order', async () => { + mockSdk.store.order.retrieve.mockResolvedValue({ order: minimalOrder }); const result = await firstValueFrom(service.getOrder({ id: 'order_1' }, 'Bearer token')); - expect(mockSdk.admin.order.retrieve).toHaveBeenCalledWith( + expect(mockSdk.store.order.retrieve).toHaveBeenCalledWith( 'order_1', - expect.objectContaining({ fields: 'items.product.*' }), + expect.objectContaining({ fields: expect.any(String) }), + expect.any(Object), ); expect(result).toBeDefined(); expect(result?.id).toBe('order_1'); @@ -104,7 +104,7 @@ describe('OrdersService', () => { }); it('should throw NotFoundException when SDK returns 404', async () => { - mockSdk.admin.order.retrieve.mockRejectedValue({ status: 404 }); + mockSdk.store.order.retrieve.mockRejectedValue({ status: 404 }); await expect(firstValueFrom(service.getOrder({ id: 'missing' }, 'Bearer token'))).rejects.toThrow( NotFoundException, @@ -118,32 +118,80 @@ describe('OrdersService', () => { expect(mockLogger.debug).toHaveBeenCalledWith('Authorization token not found'); }); - it('should throw UnauthorizedException when getCustomerId returns undefined', () => { - mockAuthService.getCustomerId.mockReturnValue(undefined); - expect(() => service.getOrderList({ limit: 10, offset: 0 }, 'Bearer token')).toThrow(UnauthorizedException); - expect(mockLogger.debug).toHaveBeenCalledWith('Customer ID not found in authorization token'); - }); - - it('should call sdk.admin.order.list with customerId and return mapped orders', async () => { - mockAuthService.getCustomerId.mockReturnValue('cust_1'); - mockSdk.admin.order.list.mockResolvedValue({ + it('should call sdk.store.order.list with params and return mapped orders', async () => { + mockSdk.store.order.list.mockResolvedValue({ orders: [minimalOrder], count: 1, }); const result = await firstValueFrom(service.getOrderList({ limit: 10, offset: 0 }, 'Bearer token')); - expect(mockSdk.admin.order.list).toHaveBeenCalledWith( + expect(mockSdk.store.order.list).toHaveBeenCalledWith( expect.objectContaining({ limit: 10, offset: 0, - customer_id: 'cust_1', fields: expect.any(String), }), + expect.any(Object), ); expect(result.data).toHaveLength(1); expect(result.total).toBe(1); expect(result.data[0]?.id).toBe('order_1'); }); + + it('should pass status filter to getMedusaStatus when query.status is provided', async () => { + mockSdk.store.order.list.mockResolvedValue({ orders: [], count: 0 }); + + await firstValueFrom( + service.getOrderList( + { + limit: 10, + offset: 0, + status: 'PENDING', + } as import('@o2s/framework/modules').Orders.Request.GetOrderListQuery, + 'Bearer token', + ), + ); + + expect(mockSdk.store.order.list).toHaveBeenCalledWith( + expect.objectContaining({ status: 'pending' }), + expect.any(Object), + ); + }); + + it('should map COMPLETED and CANCELLED status correctly', async () => { + mockSdk.store.order.list.mockResolvedValue({ orders: [], count: 0 }); + + await firstValueFrom( + service.getOrderList( + { + limit: 10, + offset: 0, + status: 'COMPLETED', + } as import('@o2s/framework/modules').Orders.Request.GetOrderListQuery, + 'Bearer token', + ), + ); + expect(mockSdk.store.order.list).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }), + expect.any(Object), + ); + + mockSdk.store.order.list.mockClear(); + await firstValueFrom( + service.getOrderList( + { + limit: 10, + offset: 0, + status: 'CANCELLED', + } as import('@o2s/framework/modules').Orders.Request.GetOrderListQuery, + 'Bearer token', + ), + ); + expect(mockSdk.store.order.list).toHaveBeenCalledWith( + expect.objectContaining({ status: 'canceled' }), + expect.any(Object), + ); + }); }); }); diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.ts index db046d109..fa96f83d3 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.ts @@ -1,9 +1,8 @@ import Medusa from '@medusajs/js-sdk'; import { HttpTypes, OrderStatus } from '@medusajs/types'; -import { HttpService } from '@nestjs/axios'; -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Inject, Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Observable, catchError, from } from 'rxjs'; +import { Observable, catchError, from, map } from 'rxjs'; import { LoggerService } from '@o2s/utils.logger'; @@ -11,10 +10,17 @@ import { Auth, Orders } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; -import { handleHttpError } from '../utils/handle-http-error'; +import { handleHttpError } from '../../utils/handle-http-error'; import { mapOrder, mapOrders } from './orders.mapper'; +/** + * Medusa.js implementation of the Orders service. + * + * Uses Medusa Store API for order retrieval and listing. + * Store API automatically scopes orders to the authenticated customer. + * Requires a custom Medusa auth plugin to validate SSO tokens passed via the authorization header. + */ @Injectable() export class OrdersService extends Orders.Service { private readonly sdk: Medusa; @@ -26,7 +32,6 @@ export class OrdersService extends Orders.Service { constructor( private readonly config: ConfigService, - protected httpClient: HttpService, @Inject(LoggerService) protected readonly logger: LoggerService, private readonly medusaJsService: MedusaJsService, private readonly authService: Auth.Service, @@ -36,10 +41,16 @@ export class OrdersService extends Orders.Service { this.defaultCurrency = this.config.get('DEFAULT_CURRENCY') || ''; if (!this.defaultCurrency) { - throw new Error('DEFAULT_CURRENCY is not defined'); + throw new InternalServerErrorException('DEFAULT_CURRENCY is not defined'); } } + /** + * Retrieves an order by ID using Medusa Store API. + * Store API automatically verifies the order belongs to the authenticated customer. + * + * @requires Medusa auth plugin must be configured to accept SSO tokens. + */ getOrder( params: Orders.Request.GetOrderParams, authorization: string | undefined, @@ -54,21 +65,21 @@ export class OrdersService extends Orders.Service { }; return from( - this.sdk.admin.order - .retrieve(params.id, query) - .then((order) => { - return mapOrder(order.order, this.defaultCurrency); - }) - .catch((error) => { - throw error; - }), + this.sdk.store.order.retrieve(params.id, query, this.medusaJsService.getStoreApiHeaders(authorization)), ).pipe( + map((response: { order: HttpTypes.StoreOrder }) => mapOrder(response.order, this.defaultCurrency)), catchError((error) => { return handleHttpError(error); }), ); } + /** + * Retrieves a paginated list of orders using Medusa Store API. + * Store API automatically filters orders by the authenticated customer (no customer_id filter needed). + * + * @requires Medusa auth plugin must be configured to accept SSO tokens. + */ getOrderList( query: Orders.Request.GetOrderListQuery, authorization: string | undefined, @@ -78,52 +89,25 @@ export class OrdersService extends Orders.Service { throw new UnauthorizedException('Unauthorized'); } - const customerId = this.authService.getCustomerId(authorization); - - if (!customerId) { - this.logger.debug('Customer ID not found in authorization token'); - throw new UnauthorizedException('Unauthorized'); - } - - const params: HttpTypes.AdminOrderFilters = { + // Store API filters use StoreOrderFilters which supports status, id, limit, offset, and order filters. + // Customer scoping is handled automatically by Store API based on the authorization token. + // Note: created_at date filtering is not available in Store API (unlike Admin API). + const params: HttpTypes.StoreOrderFilters = { limit: query.limit, offset: query.offset, status: query.status ? this.getMedusaStatus(query.status) : undefined, - created_at: this.createMedusaDateFilter(query.dateFrom, query.dateTo), - customer_id: customerId, order: query.sort ? query.sort : undefined, fields: this.additionalOrderListFields, }; - return from( - this.sdk.admin.order - .list(params) - .then((orders) => { - return mapOrders(orders, this.defaultCurrency); - }) - .catch((error) => { - throw error; - }), - ).pipe( + return from(this.sdk.store.order.list(params, this.medusaJsService.getStoreApiHeaders(authorization))).pipe( + map((orders: HttpTypes.StoreOrderListResponse) => mapOrders(orders, this.defaultCurrency)), catchError((error) => { return handleHttpError(error); }), ); } - private createMedusaDateFilter( - dateFrom: Date | undefined, - dateTo: Date | undefined, - ): HttpTypes.AdminOrderFilters['created_at'] { - if (!dateFrom || !dateTo) { - return { - $gte: dateFrom ? new Date(dateFrom).toISOString() : undefined, - $lte: dateTo ? new Date(dateTo).toISOString() : undefined, - }; - } - return undefined; - } - private getMedusaStatus(status: string): OrderStatus | undefined { switch (status) { case 'PENDING': diff --git a/packages/integrations/medusajs/src/modules/payments/index.ts b/packages/integrations/medusajs/src/modules/payments/index.ts new file mode 100644 index 000000000..7f384dad6 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/payments/index.ts @@ -0,0 +1,7 @@ +import { Payments } from '@o2s/framework/modules'; + +export { PaymentsService as Service } from './payments.service'; +export * as Mapper from './payments.mapper'; + +export import Request = Payments.Request; +export import Model = Payments.Model; diff --git a/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts new file mode 100644 index 000000000..8c2fd88cc --- /dev/null +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts @@ -0,0 +1,111 @@ +import { HttpTypes } from '@medusajs/types'; +import { describe, expect, it } from 'vitest'; + +import { mapPaymentProvider, mapPaymentProviders, mapPaymentSession } from './payments.mapper'; + +describe('payments.mapper', () => { + describe('mapPaymentProvider', () => { + it('should map id and type from provider id', () => { + const provider = { id: 'pp_stripe_123' } as HttpTypes.StorePaymentProvider; + const result = mapPaymentProvider(provider); + expect(result.id).toBe('pp_stripe_123'); + expect(result.type).toBe('pp_stripe_123'); + expect(result.name).toBe('pp_stripe_123'); + }); + + it('should set requiresRedirect for stripe and paypal', () => { + expect(mapPaymentProvider({ id: 'pp_stripe' } as HttpTypes.StorePaymentProvider).requiresRedirect).toBe( + true, + ); + expect(mapPaymentProvider({ id: 'pp_paypal' } as HttpTypes.StorePaymentProvider).requiresRedirect).toBe( + true, + ); + }); + + it('should set requiresRedirect false for other providers', () => { + const result = mapPaymentProvider({ id: 'pp_manual' } as HttpTypes.StorePaymentProvider); + expect(result.requiresRedirect).toBe(false); + }); + }); + + describe('mapPaymentProviders', () => { + it('should return mapper providers', () => { + const providers = [ + { id: 'pp_1' } as HttpTypes.StorePaymentProvider, + { id: 'pp_2' } as HttpTypes.StorePaymentProvider, + { id: 'pp_3' } as HttpTypes.StorePaymentProvider, + ]; + const result = mapPaymentProviders(providers); + expect(result.data).toHaveLength(3); + expect(result.total).toBe(3); + expect(result.data[0]?.id).toBe('pp_1'); + expect(result.data[1]?.id).toBe('pp_2'); + expect(result.data[2]?.id).toBe('pp_3'); + }); + }); + + describe('mapPaymentSession', () => { + it('should map id, cartId, providerId, status, redirectUrl, clientSecret', () => { + const session = { + id: 'ps_1', + provider_id: 'pp_stripe', + status: 'authorized', + data: { + redirect_url: 'https://example.com/redirect', + client_secret: 'secret_123', + }, + } as unknown as HttpTypes.StorePaymentSession; + const result = mapPaymentSession(session, 'cart_1'); + expect(result.id).toBe('ps_1'); + expect(result.cartId).toBe('cart_1'); + expect(result.providerId).toBe('pp_stripe'); + expect(result.status).toBe('AUTHORIZED'); + expect(result.redirectUrl).toBe('https://example.com/redirect'); + expect(result.clientSecret).toBe('secret_123'); + }); + + it('should map status captured', () => { + const session = { + id: 'ps_1', + provider_id: 'pp_1', + status: 'captured', + data: {}, + } as HttpTypes.StorePaymentSession; + const result = mapPaymentSession(session, 'cart_1'); + expect(result.status).toBe('CAPTURED'); + }); + + it('should map status cancelled', () => { + const session = { + id: 'ps_1', + provider_id: 'pp_1', + status: 'cancelled', + data: {}, + } as unknown as HttpTypes.StorePaymentSession; + const result = mapPaymentSession(session, 'cart_1'); + expect(result.status).toBe('CANCELLED'); + }); + + it('should map status failed', () => { + const session = { + id: 'ps_1', + provider_id: 'pp_1', + status: 'failed', + data: {}, + } as unknown as HttpTypes.StorePaymentSession; + const result = mapPaymentSession(session, 'cart_1'); + expect(result.status).toBe('FAILED'); + }); + + it('should map unknown status to PENDING', () => { + const session = { + id: 'ps_1', + provider_id: 'pp_1', + status: 'unknown', + data: {}, + } as unknown as HttpTypes.StorePaymentSession; + const result = mapPaymentSession(session, 'cart_1'); + expect(result.status).toBe('PENDING'); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts new file mode 100644 index 000000000..1838c2d4a --- /dev/null +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts @@ -0,0 +1,56 @@ +import { HttpTypes } from '@medusajs/types'; + +import { Payments } from '@o2s/framework/modules'; + +export function mapPaymentProvider(provider: HttpTypes.StorePaymentProvider): Payments.Model.PaymentProvider { + return { + id: provider.id, + name: provider.id, // Medusa doesn't provide a name or type, use ID + type: provider.id, + isEnabled: true, // Assume enabled if returned by API + requiresRedirect: provider.id.includes('stripe') || provider.id.includes('paypal'), + config: {}, + }; +} + +export function mapPaymentProviders( + medusaProviders: HttpTypes.StorePaymentProvider[], +): Payments.Model.PaymentProviders { + const providers = medusaProviders.map(mapPaymentProvider); + + return { + data: providers, + total: providers.length, + }; +} + +export function mapPaymentSession( + session: HttpTypes.StorePaymentSession, + cartId: string, +): Payments.Model.PaymentSession { + return { + id: session.id, + cartId, + providerId: session.provider_id, + status: mapPaymentSessionStatus(session.status), + redirectUrl: session.data?.redirect_url as string | undefined, + clientSecret: session.data?.client_secret as string | undefined, + expiresAt: undefined, // Medusa Store API does not expose expires_at on payment sessions + metadata: session.data as Record | undefined, + }; +} + +function mapPaymentSessionStatus(status: string): Payments.Model.PaymentSessionStatus { + switch (status.toUpperCase()) { + case 'AUTHORIZED': + return 'AUTHORIZED'; + case 'CAPTURED': + return 'CAPTURED'; + case 'CANCELLED': + return 'CANCELLED'; + case 'FAILED': + return 'FAILED'; + default: + return 'PENDING'; + } +} diff --git a/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts new file mode 100644 index 000000000..0773a6ca5 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts @@ -0,0 +1,244 @@ +import { HttpTypes } from '@medusajs/types'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, + NotImplementedException, +} from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Payments } from '@o2s/framework/modules'; + +import { PaymentsService } from './payments.service'; + +const BASE_URL = 'https://api.medusa.test'; + +const minimalCart = { + id: 'cart_1', + currency_code: 'eur', + items: [], +}; + +const minimalPaymentSession = { + id: 'ps_1', + provider_id: 'pp_stripe', + status: 'pending', + data: { redirect_url: 'https://example.com', client_secret: 'secret' }, +}; + +describe('PaymentsService', () => { + let service: PaymentsService; + let mockSdk: { + store: { + payment: { + listPaymentProviders: ReturnType; + initiatePaymentSession: ReturnType; + }; + cart: { retrieve: ReturnType }; + }; + }; + let mockMedusaJsService: { + getSdk: ReturnType; + getStoreApiHeaders: ReturnType; + getBaseUrl: ReturnType; + }; + let mockHttpService: { post: ReturnType; delete: ReturnType }; + let mockLogger: { debug: ReturnType; warn: ReturnType }; + + beforeEach(() => { + vi.restoreAllMocks(); + mockSdk = { + store: { + payment: { + listPaymentProviders: vi.fn(), + initiatePaymentSession: vi.fn(), + }, + cart: { + retrieve: vi.fn(), + }, + }, + }; + mockMedusaJsService = { + getSdk: vi.fn(() => mockSdk), + getStoreApiHeaders: vi.fn(() => ({})), + getBaseUrl: vi.fn(() => BASE_URL), + }; + mockHttpService = { + post: vi.fn(), + delete: vi.fn(), + }; + mockLogger = { debug: vi.fn(), warn: vi.fn() }; + + service = new PaymentsService( + mockHttpService as never, + mockLogger as unknown as import('@o2s/utils.logger').LoggerService, + mockMedusaJsService as unknown as import('@/modules/medusajs').Service, + ); + }); + + describe('getProviders', () => { + it('should throw BadRequestException when regionId is missing', async () => { + await expect( + firstValueFrom(service.getProviders({} as Payments.Request.GetProvidersParams, 'Bearer token')), + ).rejects.toThrow(BadRequestException); + await expect( + firstValueFrom(service.getProviders({} as Payments.Request.GetProvidersParams, 'Bearer token')), + ).rejects.toThrow('regionId is required'); + }); + + it('should call listPaymentProviders and return mapped providers', async () => { + mockSdk.store.payment.listPaymentProviders.mockResolvedValue({ + payment_providers: [{ id: 'pp_stripe' }], + } as HttpTypes.StorePaymentProviderListResponse); + + const result = await firstValueFrom( + service.getProviders({ regionId: 'reg_1' } as Payments.Request.GetProvidersParams, 'Bearer token'), + ); + + expect(mockSdk.store.payment.listPaymentProviders).toHaveBeenCalledWith( + { region_id: 'reg_1' }, + expect.any(Object), + ); + expect(result.data).toHaveLength(1); + expect(result.data[0]?.id).toBe('pp_stripe'); + expect(result.total).toBe(1); + }); + + it('should return empty list on error without throwing', async () => { + mockSdk.store.payment.listPaymentProviders.mockRejectedValue(new Error('Network error')); + + const result = await firstValueFrom( + service.getProviders({ regionId: 'reg_1' } as Payments.Request.GetProvidersParams, 'Bearer token'), + ); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to fetch payment providers from Medusa', + expect.any(Error), + ); + }); + }); + + describe('createSession', () => { + it('should retrieve cart then initiatePaymentSession and return mapPaymentSession', async () => { + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); + mockSdk.store.payment.initiatePaymentSession.mockResolvedValue({ + payment_collection: { + payment_sessions: [minimalPaymentSession], + }, + } as unknown as HttpTypes.StorePaymentCollectionResponse); + + const result = await firstValueFrom( + service.createSession( + { cartId: 'cart_1', providerId: 'pp_stripe' } as Payments.Request.CreateSessionBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith('cart_1', {}, expect.any(Object)); + expect(mockSdk.store.payment.initiatePaymentSession).toHaveBeenCalledWith( + minimalCart, + { provider_id: 'pp_stripe' }, + {}, + expect.any(Object), + ); + expect(result.id).toBe('ps_1'); + expect(result.cartId).toBe('cart_1'); + expect(result.providerId).toBe('pp_stripe'); + }); + + it('should use session fallback when no exact provider match', async () => { + const otherSession = { + id: 'ps_other', + provider_id: 'pp_manual', + status: 'pending', + data: {}, + }; + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); + mockSdk.store.payment.initiatePaymentSession.mockResolvedValue({ + payment_collection: { + payment_sessions: [otherSession], + }, + } as unknown as HttpTypes.StorePaymentCollectionResponse); + + const result = await firstValueFrom( + service.createSession( + { cartId: 'cart_1', providerId: 'pp_stripe' } as Payments.Request.CreateSessionBody, + 'Bearer token', + ), + ); + + expect(result.id).toBe('ps_other'); + expect(result.providerId).toBe('pp_manual'); + }); + + it('should throw InternalServerErrorException when sessions array is empty', async () => { + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); + mockSdk.store.payment.initiatePaymentSession.mockResolvedValue({ + payment_collection: { payment_sessions: [] }, + } as unknown as HttpTypes.StorePaymentCollectionResponse); + + await expect( + firstValueFrom( + service.createSession( + { cartId: 'cart_1', providerId: 'pp_stripe' } as Payments.Request.CreateSessionBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(InternalServerErrorException); + await expect( + firstValueFrom( + service.createSession( + { cartId: 'cart_1', providerId: 'pp_stripe' } as Payments.Request.CreateSessionBody, + 'Bearer token', + ), + ), + ).rejects.toThrow('Failed to create payment session'); + }); + + it('should throw NotFoundException when cart is 404', async () => { + mockSdk.store.cart.retrieve.mockRejectedValue({ response: { status: 404 } }); + + await expect( + firstValueFrom( + service.createSession( + { cartId: 'missing' as string, providerId: 'pp_stripe' } as Payments.Request.CreateSessionBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getSession', () => { + it('should throw NotImplementedException', async () => { + await expect( + firstValueFrom(service.getSession({ id: 'ps_1' } as Payments.Request.GetSessionParams, 'Bearer token')), + ).rejects.toThrow(NotImplementedException); + }); + }); + + describe('updateSession', () => { + it('should throw NotImplementedException', async () => { + await expect( + firstValueFrom( + service.updateSession( + { id: 'ps_1' } as Payments.Request.UpdateSessionParams, + { returnUrl: 'https://example.com/return' } as Payments.Request.UpdateSessionBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(NotImplementedException); + }); + }); + + describe('cancelSession', () => { + it('should throw NotImplementedException', () => { + expect(() => { + service.cancelSession({ id: 'ps_1' } as Payments.Request.CancelSessionParams, 'Bearer token'); + }).toThrow(NotImplementedException); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/payments/payments.service.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.ts new file mode 100644 index 000000000..a0d273dfd --- /dev/null +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.ts @@ -0,0 +1,159 @@ +import Medusa from '@medusajs/js-sdk'; +import { HttpTypes } from '@medusajs/types'; +import { HttpService } from '@nestjs/axios'; +import { + BadRequestException, + Inject, + Injectable, + InternalServerErrorException, + NotFoundException, + NotImplementedException, +} from '@nestjs/common'; +import { Observable, catchError, from, map, of, switchMap, throwError } from 'rxjs'; + +import { LoggerService } from '@o2s/utils.logger'; + +import { Payments } from '@o2s/framework/modules'; + +import { Service as MedusaJsService } from '@/modules/medusajs'; + +import { handleHttpError } from '../../utils/handle-http-error'; + +import { mapPaymentProviders, mapPaymentSession } from './payments.mapper'; + +/** + * Medusa.js implementation of the Payments service. + * + * Uses Medusa Store API for payment operations. + * Requires a custom Medusa auth plugin to validate SSO tokens passed via the authorization header. + * + * ## SDK Usage Status + * + * - ✅ `getProviders` - Uses SDK: `sdk.store.payment.listPaymentProviders()` + * - ✅ `createSession` - Uses SDK: `sdk.store.payment.initiatePaymentSession()` + * - ❌ `getSession` - Not implemented (SDK method not available) + * - ❌ `updateSession` - Not implemented (SDK method not available) + * - ❌ `cancelSession` - Not implemented (SDK method not available) + * + * The `getSession`, `updateSession`, and `cancelSession` methods throw `NotImplementedException` + * because the Medusa.js SDK does not currently expose methods for these operations. + */ +@Injectable() +export class PaymentsService extends Payments.Service { + private readonly sdk: Medusa; + + constructor( + protected httpClient: HttpService, + @Inject(LoggerService) protected readonly logger: LoggerService, + private readonly medusaJsService: MedusaJsService, + ) { + super(); + this.sdk = this.medusaJsService.getSdk(); + } + + getProviders( + params: Payments.Request.GetProvidersParams, + authorization: string | undefined, + ): Observable { + if (!params.regionId) { + return throwError(() => new BadRequestException('regionId is required to list payment providers')); + } + return from( + this.sdk.store.payment.listPaymentProviders( + { region_id: params.regionId }, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StorePaymentProviderListResponse) => { + const providers = response.payment_providers || []; + return mapPaymentProviders(providers); + }), + catchError((error) => { + if (error.response?.status === 401 || error.response?.status === 403) { + return handleHttpError(error); + } + this.logger.warn('Failed to fetch payment providers from Medusa', error); + return of(mapPaymentProviders([])); + }), + ); + } + + createSession( + data: Payments.Request.CreateSessionBody, + authorization: string | undefined, + ): Observable { + // Use SDK's initiatePaymentSession which handles both payment collection creation + // and payment session creation in a single call + return from( + this.sdk.store.cart.retrieve(data.cartId, {}, this.medusaJsService.getStoreApiHeaders(authorization)), + ).pipe( + switchMap((cartResponse: HttpTypes.StoreCartResponse) => { + const cart = cartResponse.cart; + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${data.cartId} not found`)); + } + + return from( + this.sdk.store.payment.initiatePaymentSession( + cart, + { provider_id: data.providerId }, + {}, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StorePaymentCollectionResponse) => { + const paymentCollection = response.payment_collection; + const sessions = paymentCollection?.payment_sessions || []; + // Find the session for the requested provider + const session = + sessions.find((s) => s.provider_id === data.providerId) || sessions[sessions.length - 1]; + if (!session) { + throw new InternalServerErrorException('Failed to create payment session'); + } + return mapPaymentSession(session, data.cartId); + }), + ); + }), + catchError((error) => { + if (error.response?.status === 404) { + return throwError(() => new NotFoundException(`Cart with ID ${data.cartId} not found`)); + } + return handleHttpError(error); + }), + ); + } + + /** + * Retrieves a payment session. + * + * @note Not implemented - The Medusa.js SDK does not provide methods for fetching payment sessions. + */ + getSession( + _params: Payments.Request.GetSessionParams, + _authorization: string | undefined, + ): Observable { + return throwError(() => new NotImplementedException()); + } + + /** + * Updates a payment session. + * + * @note Not implemented - The Medusa.js SDK does not provide methods for updating payment sessions. + */ + updateSession( + _params: Payments.Request.UpdateSessionParams, + _data: Payments.Request.UpdateSessionBody, + _authorization: string | undefined, + ): Observable { + return throwError(() => new NotImplementedException()); + } + + /** + * Cancels (deletes) a payment session. + * + * @note Not implemented - The Medusa.js SDK does not provide methods for deleting payment sessions. + */ + cancelSession(_params: Payments.Request.CancelSessionParams, _authorization: string | undefined): Observable { + throw new NotImplementedException(); + } +} diff --git a/packages/integrations/medusajs/src/modules/products/products.mapper.ts b/packages/integrations/medusajs/src/modules/products/products.mapper.ts index f20f8afea..8683aefe5 100644 --- a/packages/integrations/medusajs/src/modules/products/products.mapper.ts +++ b/packages/integrations/medusajs/src/modules/products/products.mapper.ts @@ -47,6 +47,22 @@ const mapPrice = ( }; }; +// Helper to extract prices from variant (handles Store API variants and Admin API variants for compatibility) +// Store API variants have prices via field expansion (*variants.prices) +const getVariantPrices = ( + variant: HttpTypes.AdminProductVariant | HttpTypes.StoreProductVariant, +): { currency_code?: string; amount?: number }[] | null | undefined => { + // Check if variant has prices property (Store API via field expansion, or Admin API) + if ('prices' in variant && variant.prices) { + return variant.prices as { currency_code?: string; amount?: number }[]; + } + // Type assertion for Store API variants where TypeScript types may not include prices + const storeVariant = variant as HttpTypes.StoreProductVariant & { + prices?: Array<{ currency_code?: string; amount?: number }>; + }; + return storeVariant.prices || null; +}; + // Map Medusa thumbnail to O2S Image const mapThumbnail = (thumbnail: string | null | undefined, alt: string): { url: string; alt: string } | undefined => { if (!thumbnail) return undefined; @@ -74,9 +90,11 @@ const mapTags = ( // Returns all known spec fields (weight, height, width, length, material, origin_country, hs_code, mid_code). // Variant attributes take precedence over product attributes. // The block layer will filter and format these based on CMS configuration. -const collectVariantAttributes = (variant: HttpTypes.AdminProductVariant): Products.Model.ProductAttributes => { +// Accepts both AdminProductVariant and StoreProductVariant types +const collectVariantAttributes = (variant: HttpTypes.StoreProductVariant): Products.Model.ProductAttributes => { const attributes: Products.Model.ProductAttributes = {}; - const product = variant.product; + // Store API variants have product nested, Admin API variants have it as a property + const product = 'product' in variant ? variant.product : undefined; // List of known spec fields to collect from Medusa // These should match the fields requested in productDetailFields @@ -84,8 +102,8 @@ const collectVariantAttributes = (variant: HttpTypes.AdminProductVariant): Produ for (const field of knownFields) { // Check variant first, then fall back to product - const variantValue = variant[field as keyof HttpTypes.AdminProductVariant]; - const productValue = product?.[field as keyof HttpTypes.AdminProduct]; + const variantValue = variant[field as keyof typeof variant]; + const productValue = product?.[field as keyof typeof product]; const value = variantValue ?? productValue; if (value) { @@ -98,9 +116,10 @@ const collectVariantAttributes = (variant: HttpTypes.AdminProductVariant): Produ // Calculate if a variant is in stock based on Medusa inventory data // inventory_quantity comes from Store API field expansion (+variants.inventory_quantity) -type VariantWithInventory = HttpTypes.AdminProductVariant & { inventory_quantity?: number | null }; +type VariantWithInventory = HttpTypes.StoreProductVariant & { inventory_quantity?: number | null }; -const isVariantInStock = (variant: HttpTypes.AdminProductVariant): boolean => { +// Accepts both AdminProductVariant and StoreProductVariant types +const isVariantInStock = (variant: HttpTypes.StoreProductVariant): boolean => { // If inventory is not managed, always in stock if (!variant.manage_inventory) { return true; @@ -124,8 +143,10 @@ const isVariantInStock = (variant: HttpTypes.AdminProductVariant): boolean => { }; // Map Medusa variant options to Record - -const getVariantOptionsMap = (variant: HttpTypes.AdminProductVariant): Record => { +// Accepts StoreProductVariant (primary) and AdminProductVariant (for compatibility) +const getVariantOptionsMap = ( + variant: HttpTypes.AdminProductVariant | HttpTypes.StoreProductVariant, +): Record => { const options = variant.options; if (!options || !Array.isArray(options)) { return {}; @@ -142,8 +163,9 @@ const getVariantOptionsMap = (variant: HttpTypes.AdminProductVariant): Record { const groupsById = new Map }>(); @@ -216,7 +239,8 @@ const mapOptionGroups = ( }; // Get a display title for a variant from its options or title -const getVariantTitle = (variant: HttpTypes.AdminProductVariant): string => { +// Accepts StoreProductVariant (primary) and AdminProductVariant (for compatibility) +const getVariantTitle = (variant: HttpTypes.AdminProductVariant | HttpTypes.StoreProductVariant): string => { if (variant.options && Array.isArray(variant.options) && variant.options.length > 0) { return variant.options .map((option: { value?: string }) => option.value) @@ -227,9 +251,9 @@ const getVariantTitle = (variant: HttpTypes.AdminProductVariant): string => { }; export const mapProduct = ( - productVariant: HttpTypes.AdminProductVariant, + productVariant: HttpTypes.StoreProductVariant, defaultCurrency: string, - allVariants: HttpTypes.AdminProductVariant[] | undefined, + allVariants: HttpTypes.StoreProductVariant[] | undefined, basePath: string, variantOptionGroups?: { medusaTitle: string; label: string }[], ): Products.Model.Product => { @@ -237,7 +261,8 @@ export const mapProduct = ( throw new NotFoundException('Product variant is undefined'); } - const product = productVariant?.product; + // Store API variants have product nested, Admin API variants have it as a property + const product = 'product' in productVariant ? productVariant.product : undefined; // Validate required fields if (!product?.id) { @@ -259,7 +284,7 @@ export const mapProduct = ( variantId: productVariant.id, image: mapThumbnail(product?.thumbnail, product?.title || ''), images: mapImages(product?.images, product?.title || ''), - price: mapPrice(productVariant.prices, defaultCurrency), + price: mapPrice(getVariantPrices(productVariant), defaultCurrency), link: generateProductLink(basePath, product?.handle, product?.id || '', productVariant.sku), type: mapProductType(product?.type || undefined), category: product?.categories?.[0]?.name || '', @@ -273,8 +298,12 @@ export const mapProduct = ( }; }; +/** + * Maps a list of Medusa products to O2S Products model. + * Accepts StoreProductListResponse (primary, from Store API) and AdminProductListResponse (for compatibility). + */ export const mapProducts = ( - data: HttpTypes.AdminProductListResponse, + data: HttpTypes.StoreProductListResponse | HttpTypes.AdminProductListResponse, defaultCurrency: string, basePath: string, categoryFilter?: string, @@ -312,7 +341,7 @@ export const mapProducts = ( variantId: firstVariant?.id || '', image: mapThumbnail(product?.thumbnail, product.title), images: mapImages(product.images, product.title), - price: mapPrice(firstVariant?.prices, defaultCurrency), + price: mapPrice(getVariantPrices(firstVariant), defaultCurrency), link: generateProductLink(basePath, product.handle, product.id, firstVariant?.sku), type: mapProductType(product?.type || undefined), category: product.categories?.[0]?.name || '', diff --git a/packages/integrations/medusajs/src/modules/products/products.service.spec.ts b/packages/integrations/medusajs/src/modules/products/products.service.spec.ts index 0f12de646..cec9a956f 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.spec.ts @@ -58,32 +58,24 @@ describe('ProductsService', () => { getSdk: ReturnType; getBaseUrl: ReturnType; getMedusaAdminApiHeaders: ReturnType; + getStoreApiHeaders: ReturnType; }; let mockConfig: { get: ReturnType }; let mockLogger: { debug: ReturnType }; - let mockSdkProductList: ReturnType; - let mockSdkProductRetrieve: ReturnType; - let mockSdkProductRetrieveVariant: ReturnType; + let mockSdkStoreProductList: ReturnType; + let mockSdkStoreProductRetrieve: ReturnType; beforeEach(() => { vi.restoreAllMocks(); mockHttpClient = { get: vi.fn() }; - mockSdkProductList = vi.fn(); - mockSdkProductRetrieve = vi.fn(); - mockSdkProductRetrieveVariant = vi.fn(); + mockSdkStoreProductList = vi.fn(); + mockSdkStoreProductRetrieve = vi.fn(); mockMedusaJsService = { getSdk: vi.fn(() => ({ - admin: { - product: { - list: mockSdkProductList, - retrieve: mockSdkProductRetrieve, - retrieveVariant: mockSdkProductRetrieveVariant, - }, - }, store: { product: { - list: vi.fn(), - retrieve: mockSdkProductRetrieve, + list: mockSdkStoreProductList, + retrieve: mockSdkStoreProductRetrieve, }, }, })), @@ -92,6 +84,9 @@ describe('ProductsService', () => { 'x-publishable-api-key': 'pk', Authorization: 'Basic xxx', })), + getStoreApiHeaders: vi.fn(() => ({ + 'x-publishable-api-key': 'pk', + })), }; mockConfig = { get: vi.fn((key: string) => (key === 'DEFAULT_CURRENCY' ? DEFAULT_CURRENCY : '')), @@ -123,18 +118,19 @@ describe('ProductsService', () => { }); describe('getProductList', () => { - it('should call sdk.admin.product.list and return mapped products', async () => { - mockSdkProductList.mockResolvedValue(mockProductListResponse); + it('should call sdk.store.product.list and return mapped products', async () => { + mockSdkStoreProductList.mockResolvedValue(mockProductListResponse); const result = await firstValueFrom( service.getProductList({ limit: 10, offset: 0, basePath: TEST_BASE_PATH }), ); - expect(mockSdkProductList).toHaveBeenCalledWith( + expect(mockSdkStoreProductList).toHaveBeenCalledWith( expect.objectContaining({ limit: 10, offset: 0, }), + expect.any(Object), ); expect(result.data).toHaveLength(1); expect(result.total).toBe(1); @@ -143,7 +139,7 @@ describe('ProductsService', () => { }); it('should throw NotFoundException when SDK returns 404', async () => { - mockSdkProductList.mockRejectedValue({ status: 404 }); + mockSdkStoreProductList.mockRejectedValue({ status: 404 }); await expect( firstValueFrom(service.getProductList({ limit: 10, offset: 0, basePath: TEST_BASE_PATH })), @@ -151,7 +147,7 @@ describe('ProductsService', () => { }); it('should use empty string as default basePath when not provided', async () => { - mockSdkProductList.mockResolvedValue(mockProductListResponse); + mockSdkStoreProductList.mockResolvedValue(mockProductListResponse); const result = await firstValueFrom(service.getProductList({ limit: 10, offset: 0 })); @@ -161,9 +157,24 @@ describe('ProductsService', () => { }); describe('getProduct', () => { - it('should call sdk to retrieve product and variant and return mapped product', async () => { - mockSdkProductRetrieve.mockResolvedValue(mockRetrieveResponse); - mockSdkProductRetrieveVariant.mockResolvedValue(mockVariantResponse); + it('should call sdk.store.product.retrieve to get product and variant and return mapped product', async () => { + // First call: retrieve product to get variants list + mockSdkStoreProductRetrieve + .mockResolvedValueOnce(mockRetrieveResponse) + // Second call: retrieve product with variant details + .mockResolvedValueOnce({ + product: { + ...mockRetrieveResponse.product, + variants: [ + { + ...mockVariantResponse.variant, + id: 'var_1', + sku: 'SKU1', + prices: [{ currency_code: 'eur', amount: 1999 }], + }, + ], + }, + }); const result = await firstValueFrom( service.getProduct({ @@ -173,15 +184,14 @@ describe('ProductsService', () => { }), ); - expect(mockSdkProductRetrieve).toHaveBeenCalledWith('prod_1', expect.any(Object)); - expect(mockSdkProductRetrieveVariant).toHaveBeenCalledWith('prod_1', 'var_1', expect.any(Object)); + expect(mockSdkStoreProductRetrieve).toHaveBeenCalledWith('prod_1', expect.any(Object), expect.any(Object)); expect(result.id).toBe('prod_1'); expect(result.variantId).toBe('var_1'); expect(result.price.value).toBe(1999); }); it('should throw NotFoundException when product has no variants', async () => { - mockSdkProductRetrieve.mockResolvedValue({ product: { id: 'prod_1', variants: [] } }); + mockSdkStoreProductRetrieve.mockResolvedValue({ product: { id: 'prod_1', variants: [] } }); await expect( firstValueFrom( @@ -194,8 +204,12 @@ describe('ProductsService', () => { }); it('should throw NotFoundException when variant not found', async () => { - mockSdkProductRetrieve.mockResolvedValue(mockRetrieveResponse); - mockSdkProductRetrieveVariant.mockResolvedValue({ variant: null }); + mockSdkStoreProductRetrieve.mockResolvedValueOnce(mockRetrieveResponse).mockResolvedValueOnce({ + product: { + ...mockRetrieveResponse.product, + variants: [{ id: 'var_2', sku: 'SKU2' }], + }, + }); await expect( firstValueFrom( diff --git a/packages/integrations/medusajs/src/modules/products/products.service.ts b/packages/integrations/medusajs/src/modules/products/products.service.ts index 4b0928d90..4ed73ce3b 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.ts @@ -12,25 +12,35 @@ import { Products } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; -import { handleHttpError } from '../utils/handle-http-error'; +import { handleHttpError } from '../../utils/handle-http-error'; import { mapProduct, mapProducts, mapRelatedProducts } from './products.mapper'; import { RelatedProductsResponse } from './response.types'; +/** + * Medusa.js implementation of the Products service. + * + * Uses Medusa Store API for product operations (list, retrieve, variant details). + * Store API automatically filters to published products and supports customer-facing features. + * Requires a custom Medusa auth plugin to validate SSO tokens passed via the authorization header. + * + * Note: getRelatedProductList() uses Admin API as it requires a custom endpoint not available in Store API. + */ @Injectable() export class ProductsService extends Products.Service { private readonly sdk: Medusa; private readonly defaultCurrency: string; + // Store API fields for listing products with variants (includes inventory_quantity) // Note: handle is included by default in Medusa product response private readonly productListFields = '*variants,*variants.prices,*variants.options,*categories,*tags,*images,+variants.inventory_quantity,+variants.manage_inventory,+variants.allow_backorder'; // Store API fields for retrieving product with variants (includes inventory_quantity) private readonly storeRetrieveFields = '*variants,*variants.options,*variants.options.option,+variants.inventory_quantity,+variants.manage_inventory,+variants.allow_backorder'; - // Admin API fields for retrieving single variant details (weight, height, etc.) + // Store API fields for retrieving product with variant details (weight, height, etc.) private readonly productDetailFields = - '+weight,+height,+width,+length,+material,+origin_country,+hs_code,+mid_code,+metadata,+product.metadata,+product.handle,product.*,*product.images,*product.tags,*options,*options.option'; + '*variants,+variants.weight,+variants.height,+variants.width,+variants.length,+variants.material,+variants.origin_country,+variants.hs_code,+variants.mid_code,+variants.metadata,+variants.prices,*variants.options,*variants.options.option,+metadata,+handle,+,+images,+tags'; constructor( private readonly config: ConfigService, @@ -47,18 +57,22 @@ export class ProductsService extends Products.Service { } } - getProductList(query: Products.Request.GetProductListQuery): Observable { - const params: HttpTypes.AdminProductListParams = { + getProductList( + query: Products.Request.GetProductListQuery, + authorization?: string, + ): Observable { + const params: HttpTypes.StoreProductListParams = { limit: query.limit, offset: query.offset, - status: ['published'], fields: this.productListFields, }; + const headers = this.medusaJsService.getStoreApiHeaders(authorization); + return from( - this.sdk.admin.product - .list(params) - .then((response) => { + this.sdk.store.product + .list(params, headers) + .then((response: HttpTypes.StoreProductListResponse) => { return mapProducts(response, this.defaultCurrency, query.basePath || '', query.category); }) .catch((error) => { @@ -71,16 +85,28 @@ export class ProductsService extends Products.Service { ); } - getProduct(params: Products.Request.GetProductParams): Observable { + getProduct(params: Products.Request.GetProductParams, authorization?: string): Observable { // Check if id is a product ID (starts with prod_) or a handle const isProductId = params.id.startsWith('prod_'); if (isProductId) { - return this.getProductById(params.id, params.variantId, params.basePath, params.variantOptionGroups); + return this.getProductById( + params.id, + params.variantId, + params.basePath, + params.variantOptionGroups, + authorization, + ); } // Treat as handle - search for product by handle - return this.getProductByHandle(params.id, params.variantId, params.basePath, params.variantOptionGroups); + return this.getProductByHandle( + params.id, + params.variantId, + params.basePath, + params.variantOptionGroups, + authorization, + ); } private getProductById( @@ -88,13 +114,15 @@ export class ProductsService extends Products.Service { variantId?: string, basePath?: string, variantOptionGroups?: { medusaTitle: string; label: string }[], + authorization?: string, ): Observable { + const headers = this.medusaJsService.getStoreApiHeaders(authorization); return from( - this.sdk.store.product.retrieve(productId, { fields: this.storeRetrieveFields }).catch((error) => { + this.sdk.store.product.retrieve(productId, { fields: this.storeRetrieveFields }, headers).catch((error) => { throw error; }), ).pipe( - switchMap((response) => { + switchMap((response: HttpTypes.StoreProductResponse) => { const product = response.product; if (!product?.variants?.length) { throw new NotFoundException(`No variants found for product ${productId}`); @@ -103,9 +131,10 @@ export class ProductsService extends Products.Service { return this.getVariant( productId, targetVariantId, - product.variants as HttpTypes.AdminProductVariant[], + product.variants, basePath, variantOptionGroups, + authorization, ); }), catchError((error) => { @@ -119,13 +148,17 @@ export class ProductsService extends Products.Service { variantSlug?: string, basePath?: string, variantOptionGroups?: { medusaTitle: string; label: string }[], + authorization?: string, ): Observable { + const headers = this.medusaJsService.getStoreApiHeaders(authorization); return from( - this.sdk.store.product.list({ handle, limit: 1, fields: this.storeRetrieveFields }).catch((error) => { - throw error; - }), + this.sdk.store.product + .list({ handle, limit: 1, fields: this.storeRetrieveFields }, headers) + .catch((error) => { + throw error; + }), ).pipe( - switchMap((response) => { + switchMap((response: HttpTypes.StoreProductListResponse) => { const product = response.products[0]; if (!product) { throw new NotFoundException(`Product with handle "${handle}" not found`); @@ -134,19 +167,18 @@ export class ProductsService extends Products.Service { throw new NotFoundException(`No variants found for product with handle "${handle}"`); } - const allVariants = product.variants as HttpTypes.AdminProductVariant[]; + const allVariants = product.variants; // Find variant by SKU slug let variant = allVariants[0]!; if (variantSlug) { const matchingVariant = allVariants.find( - (v: HttpTypes.AdminProductVariant) => - v.sku && slugify(v.sku, { lower: true, strict: true }) === variantSlug, + (v) => v.sku && slugify(v.sku, { lower: true, strict: true }) === variantSlug, ); if (matchingVariant) { variant = matchingVariant; } else { - const availableSlugs = allVariants.map((v: HttpTypes.AdminProductVariant) => + const availableSlugs = allVariants.map((v) => v.sku ? slugify(v.sku, { lower: true, strict: true }) : v.id, ); this.logger.warn( @@ -155,7 +187,14 @@ export class ProductsService extends Products.Service { } } - return this.getVariant(product.id, variant.id, allVariants, basePath, variantOptionGroups); + return this.getVariant( + product.id, + variant.id, + allVariants, + basePath, + variantOptionGroups, + authorization, + ); }), catchError((error) => { return handleHttpError(error); @@ -166,25 +205,38 @@ export class ProductsService extends Products.Service { private getVariant( productId: string, variantId: string, - allVariants?: HttpTypes.AdminProductVariant[], + allVariants?: HttpTypes.StoreProductVariant[], basePath?: string, variantOptionGroups?: { medusaTitle: string; label: string }[], + authorization?: string, ): Observable { + const headers = this.medusaJsService.getStoreApiHeaders(authorization); return from( - this.sdk.admin.product - .retrieveVariant(productId, variantId, { fields: this.productDetailFields }) - .catch((error) => { - throw error; - }), + this.sdk.store.product.retrieve(productId, { fields: this.productDetailFields }, headers).catch((error) => { + throw error; + }), ).pipe( - map((response) => { - if (!response.variant) { + map((response: HttpTypes.StoreProductResponse) => { + const product = response.product; + if (!product?.variants?.length) { + throw new NotFoundException(`No variants found for product ${productId}`); + } + // Find the specific variant from the product's variants array + const variant = product.variants.find((v) => v.id === variantId); + if (!variant) { throw new NotFoundException(`Variant ${variantId} not found for product ${productId}`); } + // Merge variant with product data to match expected structure + const variantWithProduct = { + ...variant, + product: product, + } as HttpTypes.StoreProductVariant & { product: HttpTypes.StoreProduct }; + // Use allVariants if provided, otherwise use product.variants + const variantsToUse = allVariants || product.variants; return mapProduct( - response.variant, + variantWithProduct, this.defaultCurrency, - allVariants, + variantsToUse, basePath || '', variantOptionGroups, ); @@ -195,6 +247,10 @@ export class ProductsService extends Products.Service { ); } + /** + * Retrieves related products for a given product variant. + * Uses Admin API as this endpoint is not available in Store API. + */ getRelatedProductList(params: Products.Request.GetRelatedProductListParams): Observable { return this.httpClient .get( diff --git a/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts b/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts index ecc04327c..ba112625a 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.mapper.spec.ts @@ -127,8 +127,8 @@ describe('resources.mapper', () => { const result = mapService(service, minimalProduct, defaultCurrency); expect(result.id).toBe('svc_1'); expect(result.product).toBe(minimalProduct); - expect(result.contract.status).toBe('active'); - expect(result.contract.paymentPeriod).toBe('monthly'); + expect(result.contract.status).toBe('ACTIVE'); + expect(result.contract.paymentPeriod).toBe('MONTHLY'); expect(result.contract.price.value).toBe(999); }); diff --git a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts index edf04d698..3ba7aaf37 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts @@ -1,8 +1,8 @@ -import { AddressDTO } from '@medusajs/types'; - -import { Models, Products, Resources } from '@o2s/framework/modules'; +import { Products, Resources } from '@o2s/framework/modules'; import { Asset, AssetsResponse, ServiceInstance, ServiceInstancesResponse } from './response.types'; +import { mapAddress } from '@/utils/address'; +import { parseCurrency } from '@/utils/currency'; export const mapAsset = (asset: Asset, product: Products.Model.Product): Resources.Model.Asset => { return { @@ -67,13 +67,13 @@ export const mapService = ( contract: { id: serviceInstance.id, type: '', - status: serviceInstance?.status as Resources.Model.ContractStatus, + status: mapContractStatus(serviceInstance?.status), startDate: serviceInstance.start_date, endDate: serviceInstance?.end_date, - paymentPeriod: serviceInstance.payment_type as Resources.Model.PaymentPeriod, + paymentPeriod: mapPaymentPeriod(serviceInstance.payment_type), price: { value: serviceInstance?.totals?.total_price?.value ?? 0, - currency: mapCurrency(serviceInstance?.totals?.currency) || defaultCurrency, + currency: parseCurrency(serviceInstance?.totals?.currency ?? defaultCurrency), }, }, assets: @@ -85,29 +85,22 @@ export const mapService = ( }; }; -const mapAddress = (address: AddressDTO): Models.Address.Address | undefined => { - if (!address) return undefined; - return { - country: address.country_code || '', - district: address.province || '', - region: address.province || '', - streetName: address.address_1 || '', - streetNumber: address.address_2 || '', - city: address.city || '', - postalCode: address.postal_code || '', - phone: address.phone || '', - }; -}; +const VALID_CONTRACT_STATUSES: Resources.Model.ContractStatus[] = ['ACTIVE', 'EXPIRED', 'INACTIVE']; -const mapCurrency = (currency: string): Models.Price.Currency => { - switch (currency) { - case 'pln': - return 'PLN'; - case 'eur': - return 'EUR'; - case 'usd': - return 'USD'; - default: - return currency as Models.Price.Currency; +function mapContractStatus(status: string | undefined): Resources.Model.ContractStatus { + const normalized = (status ?? '').toUpperCase(); + if (VALID_CONTRACT_STATUSES.includes(normalized as Resources.Model.ContractStatus)) { + return normalized as Resources.Model.ContractStatus; } -}; + return 'ACTIVE'; +} + +const VALID_PAYMENT_PERIODS: Resources.Model.PaymentPeriod[] = ['ONE_TIME', 'MONTHLY', 'YEARLY', 'WEEKLY']; + +function mapPaymentPeriod(paymentType: string | undefined): Resources.Model.PaymentPeriod { + const normalized = (paymentType ?? '').toUpperCase().replace(/-/g, '_'); + if (VALID_PAYMENT_PERIODS.includes(normalized as Resources.Model.PaymentPeriod)) { + return normalized as Resources.Model.PaymentPeriod; + } + return 'ONE_TIME'; +} diff --git a/packages/integrations/medusajs/src/modules/resources/resources.service.ts b/packages/integrations/medusajs/src/modules/resources/resources.service.ts index 862ebc0fd..3bcfd177b 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.service.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.service.ts @@ -1,6 +1,6 @@ import Medusa from '@medusajs/js-sdk'; import { HttpService } from '@nestjs/axios'; -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException, NotImplementedException, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Observable, catchError, forkJoin, map, switchMap } from 'rxjs'; @@ -10,8 +10,8 @@ import { Auth, Products, Resources } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; +import { handleHttpError } from '../../utils/handle-http-error'; import { mapCompatibleServices, mapFeaturedServices } from '../products/products.mapper'; -import { handleHttpError } from '../utils/handle-http-error'; import { mapAsset, mapAssets, mapService, mapServices } from './resources.mapper'; import { @@ -53,11 +53,11 @@ export class ResourcesService extends Resources.Service { } purchaseOrActivateResource(_params: Resources.Request.GetResourceParams): Observable { - throw new Error('Method not implemented'); + throw new NotImplementedException('Method not implemented'); } purchaseOrActivateService(_params: Resources.Request.GetServiceParams): Observable { - throw new Error('Method not implemented'); + throw new NotImplementedException('Method not implemented'); } getServiceList( @@ -84,7 +84,7 @@ export class ResourcesService extends Resources.Service { switchMap(({ data }) => { const productRequests = data.serviceInstances.map((service) => { if (!service.product_variant.product_id) { - throw new Error('Product ID not found'); + throw new NotFoundException('Product ID not found'); } return this.productService.getProduct({ @@ -126,7 +126,7 @@ export class ResourcesService extends Resources.Service { .pipe( switchMap(({ data }) => { if (!data.serviceInstance.product_variant.product_id) { - throw new Error('Product ID not found'); + throw new NotFoundException('Product ID not found'); } return this.productService @@ -171,7 +171,7 @@ export class ResourcesService extends Resources.Service { switchMap(({ data }) => { const productRequests = data.assets.map((asset) => { if (!asset.product_variant.product_id) { - throw new Error('Product ID not found'); + throw new NotFoundException('Product ID not found'); } return this.productService.getProduct({ @@ -201,7 +201,7 @@ export class ResourcesService extends Resources.Service { .pipe( switchMap(({ data }) => { if (!data.asset.product_variant.product_id) { - throw new Error('Product ID not found'); + throw new NotFoundException('Product ID not found'); } return this.productService diff --git a/packages/integrations/medusajs/src/utils/address.ts b/packages/integrations/medusajs/src/utils/address.ts new file mode 100644 index 000000000..b6d1c8e65 --- /dev/null +++ b/packages/integrations/medusajs/src/utils/address.ts @@ -0,0 +1,40 @@ +import { Models } from '@o2s/framework/modules'; + +/** + * Common address fields shared across Medusa address types + */ +interface MedusaAddressFields { + first_name?: string | null; + last_name?: string | null; + country_code?: string | null; + province?: string | null; + address_1?: string | null; + address_2?: string | null; + city?: string | null; + postal_code?: string | null; + phone?: string | null; +} + +/** + * Maps a Medusa address (from StoreCartAddress, StoreOrderAddress, AddressDTO, etc.) to the framework Address model. + * Handles all Medusa address types by accepting the common fields interface. + */ +export function mapAddress(address?: MedusaAddressFields | null): Models.Address.Address | undefined { + if (!address) { + return undefined; + } + + return { + firstName: address.first_name ?? undefined, + lastName: address.last_name ?? undefined, + country: address.country_code ?? '', + district: address.province ?? '', + region: address.province ?? '', + streetName: address.address_1 ?? '', + streetNumber: undefined, // Medusa does not store street number separately + apartment: address.address_2 ?? undefined, + city: address.city ?? '', + postalCode: address.postal_code ?? '', + phone: address.phone ?? undefined, + }; +} diff --git a/packages/integrations/medusajs/src/utils/currency.ts b/packages/integrations/medusajs/src/utils/currency.ts new file mode 100644 index 000000000..11115600c --- /dev/null +++ b/packages/integrations/medusajs/src/utils/currency.ts @@ -0,0 +1,11 @@ +import { BadRequestException } from '@nestjs/common'; + +import { Models } from '@o2s/framework/modules'; + +export function parseCurrency(code: string | undefined | null): Models.Price.Currency { + if (typeof code !== 'string') { + throw new BadRequestException(`Invalid currency: ${code}`); + } + + return code.toUpperCase() as Models.Price.Currency; +} diff --git a/packages/integrations/medusajs/src/modules/utils/handle-http-error.spec.ts b/packages/integrations/medusajs/src/utils/handle-http-error.spec.ts similarity index 81% rename from packages/integrations/medusajs/src/modules/utils/handle-http-error.spec.ts rename to packages/integrations/medusajs/src/utils/handle-http-error.spec.ts index 76faf0781..7cf7e088c 100644 --- a/packages/integrations/medusajs/src/modules/utils/handle-http-error.spec.ts +++ b/packages/integrations/medusajs/src/utils/handle-http-error.spec.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ForbiddenException, InternalServerErrorException, NotFoundException, @@ -33,4 +34,11 @@ describe('handleHttpError', () => { 'Server error', ); }); + + it('should pass through HttpException (e.g. BadRequestException) without wrapping', async () => { + const error = new BadRequestException('At least one address is required'); + const obs = handleHttpError(error); + + await expect(firstValueFrom(obs)).rejects.toThrow(BadRequestException); + }); }); diff --git a/packages/integrations/medusajs/src/modules/utils/handle-http-error.ts b/packages/integrations/medusajs/src/utils/handle-http-error.ts similarity index 66% rename from packages/integrations/medusajs/src/modules/utils/handle-http-error.ts rename to packages/integrations/medusajs/src/utils/handle-http-error.ts index 4d9d353e0..1fb190a5f 100644 --- a/packages/integrations/medusajs/src/modules/utils/handle-http-error.ts +++ b/packages/integrations/medusajs/src/utils/handle-http-error.ts @@ -1,11 +1,17 @@ -import { NotFoundException } from '@nestjs/common'; -import { ForbiddenException } from '@nestjs/common'; -import { InternalServerErrorException } from '@nestjs/common'; -import { UnauthorizedException } from '@nestjs/common'; +import { + ForbiddenException, + HttpException, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { throwError } from 'rxjs'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const handleHttpError = (error: any) => { + if (error instanceof HttpException) { + return throwError(() => error); + } if (error.status === 404) { throw new NotFoundException(`Not found`); } else if (error.status === 403) { diff --git a/packages/integrations/medusajs/src/utils/metadata.ts b/packages/integrations/medusajs/src/utils/metadata.ts new file mode 100644 index 000000000..04fad0559 --- /dev/null +++ b/packages/integrations/medusajs/src/utils/metadata.ts @@ -0,0 +1,6 @@ +export function asRecord(value: unknown): Record { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} diff --git a/packages/integrations/medusajs/src/utils/price.ts b/packages/integrations/medusajs/src/utils/price.ts new file mode 100644 index 000000000..a742d3163 --- /dev/null +++ b/packages/integrations/medusajs/src/utils/price.ts @@ -0,0 +1,27 @@ +import { BadRequestException } from '@nestjs/common'; + +import { Models } from '@o2s/framework/modules'; + +/** + * Maps a price value to a Price object, throwing BadRequestException if the value is missing. + * + * @param value - The price value (can be undefined or null) + * @param currency - The currency code + * @param context - Context string describing the price field (e.g., "Cart subtotal", "Order item total") + * @returns Price object if value is present + * @throws BadRequestException if value is missing + * + * @example + * const subtotal = mapPrice(cart.subtotal, currency, `Cart ${cart.id} subtotal`); + * const total = mapPrice(cart.total, currency, `Cart ${cart.id} total`); + */ +export function mapPrice( + value: number | undefined | null, + currency: Models.Price.Currency, + context: string, +): Models.Price.Price { + if (typeof value === 'undefined' || value === null) { + throw new BadRequestException(`${context}: price value is missing or invalid`); + } + return { value, currency }; +} diff --git a/packages/integrations/mocked/src/integration.ts b/packages/integrations/mocked/src/integration.ts index a46a84c8c..0b49fec31 100644 --- a/packages/integrations/mocked/src/integration.ts +++ b/packages/integrations/mocked/src/integration.ts @@ -1,14 +1,18 @@ -import { ApiConfig, Auth, Search } from '@o2s/framework/modules'; +import { ApiConfig, Auth, Carts, Customers, Payments, Search } from '@o2s/framework/modules'; import { Service as ArticlesService } from './modules/articles'; import { Service as AuthService } from './modules/auth'; import { Service as BillingAccountsService } from './modules/billing-accounts'; import { Service as CacheService } from './modules/cache'; +import { Service as CartsService } from './modules/carts'; +import { Service as CheckoutService } from './modules/checkout'; import { Service as CmsService } from './modules/cms'; +import { Service as CustomersService } from './modules/customers'; import { Service as InvoicesService } from './modules/invoices'; import { Service as NotificationsService } from './modules/notifications'; import { Service as OrdersService } from './modules/orders'; import { Service as OrganizationsService } from './modules/organizations'; +import { Service as PaymentsService } from './modules/payments'; import { Service as ProductsService } from './modules/products'; import { Service as ResourceService } from './modules/resources'; import { Service as SearchService } from './modules/search'; @@ -73,6 +77,25 @@ export const Config: Partial = { name: 'mocked', service: ProductsService, }, + carts: { + name: 'mocked', + service: CartsService, + imports: [Auth.Module, Customers.Module], + }, + customers: { + name: 'mocked', + service: CustomersService, + imports: [Auth.Module], + }, + payments: { + name: 'mocked', + service: PaymentsService, + }, + checkout: { + name: 'mocked', + service: CheckoutService, + imports: [Auth.Module, Carts.Module, Customers.Module, Payments.Module], + }, auth: { name: 'mocked', service: AuthService, diff --git a/packages/integrations/mocked/src/modules/auth/auth.service.ts b/packages/integrations/mocked/src/modules/auth/auth.service.ts index 72645bcf7..d1b140f7c 100644 --- a/packages/integrations/mocked/src/modules/auth/auth.service.ts +++ b/packages/integrations/mocked/src/modules/auth/auth.service.ts @@ -37,7 +37,7 @@ export class AuthService extends Auth.Service { // Decode directly - already verified by guard const decodedToken = typeof token === 'string' ? (jwt.decode(token.replace('Bearer ', '')) as Jwt) : token; - return decodedToken.customer?.id; + return decodedToken?.customer?.id; } getRoles(token?: string | Jwt): Auth.Model.Role[] { diff --git a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts new file mode 100644 index 000000000..1700f08d2 --- /dev/null +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -0,0 +1,404 @@ +import { Carts, Models, Products } from '@o2s/framework/modules'; + +import { getMockProviderById, getPaymentMethodDisplay } from '../payments/mocks/providers.mock'; +import { mapProductBySku } from '../products/products.mapper'; + +// Read payment method stored in metadata by setPayment +const mapPaymentMethodFromMetadata = (metadata: Record): Carts.Model.PaymentMethod | undefined => { + const stored = metadata?.paymentMethod as Record | undefined; + if (!stored || typeof stored !== 'object') return undefined; + + return { + id: stored.id as string, + name: stored.name as string, + description: (stored.description as string) ?? undefined, + }; +}; + +// Promotions +const PROMOTIONS: Carts.Model.Promotion[] = [ + { + id: 'PROMO-001', + code: 'SAVE10', + name: '10% Off', + description: 'Get 10% off your order', + type: 'PERCENTAGE', + value: '10', + }, + { + id: 'PROMO-002', + code: 'FREESHIP', + name: 'Free Shipping', + description: 'Free standard shipping', + type: 'FREE_SHIPPING', + value: '0', + }, +]; + +const formatDate = (date: Date): string => { + return date.toISOString(); +}; + +// Build cart item from product (shared by addCartItem and generateCartItem) +const buildCartItemFromProduct = ( + product: Products.Model.Product, + quantity: number, + currency: Models.Price.Currency, + metadata: Record = {}, +): Carts.Model.CartItem => { + const price = product.price?.value ?? 0; + const subtotal = price * quantity; + + return { + id: `ITEM-${crypto.randomUUID()}`, + sku: product.sku ?? '', + quantity, + price: { value: price, currency }, + subtotal: { value: subtotal, currency }, + discountTotal: { value: 0, currency }, + total: { value: subtotal, currency }, + unit: 'PCS', + currency, + product: { + id: product.id, + sku: product.sku ?? '', + name: product.name, + description: product.description, + shortDescription: product.shortDescription, + image: product.image, + price: product.price ?? { value: price, currency }, + link: product.link ?? '', + type: product.type, + category: product.category ?? '', + tags: product.tags ?? [], + }, + metadata, + }; +}; + +// In-memory store for carts (for mutations) — starts empty, simulating real e-commerce +let cartsStore: Carts.Model.Cart[] = []; + +// Reset store (useful for testing) +export const resetCartsStore = (): void => { + cartsStore = []; +}; + +// Get cart by ID +export const mapCart = (params: Carts.Request.GetCartParams): Carts.Model.Cart | undefined => { + return cartsStore.find((cart) => cart.id === params.id); +}; + +// Get cart list with filters +export const mapCarts = (query: Carts.Request.GetCartListQuery, customerId?: string): Carts.Model.Carts => { + const { offset = 0, limit = 10, sort } = query; + + let filteredCarts = cartsStore.filter((cart) => { + if (customerId && cart.customerId !== customerId) return false; + return true; + }); + + // Sort + if (sort) { + const [field, order] = sort.split('_'); + const isAscending = order === 'ASC'; + + filteredCarts = filteredCarts.sort((a, b) => { + if (field === 'createdAt' || field === 'updatedAt') { + const aDate = new Date(a[field] as string); + const bDate = new Date(b[field] as string); + return isAscending ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime(); + } else if (field === 'total') { + return isAscending ? a.total.value - b.total.value : b.total.value - a.total.value; + } + return 0; + }); + } + + return { + data: filteredCarts.slice(offset, Number(offset) + Number(limit)), + total: filteredCarts.length, + }; +}; + +// Create a new cart +export const createCart = (data: Carts.Request.CreateCartBody): Carts.Model.Cart => { + const newId = `CART-${crypto.randomUUID()}`; + const now = new Date(); + const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + + const newCart: Carts.Model.Cart = { + id: newId, + customerId: data.customerId, + name: data.name, + createdAt: formatDate(now), + updatedAt: formatDate(now), + expiresAt: formatDate(expiresAt), + regionId: data.regionId, + currency: data.currency, + items: { + data: [], + total: 0, + }, + subtotal: { value: 0, currency: data.currency }, + discountTotal: { value: 0, currency: data.currency }, + taxTotal: { value: 0, currency: data.currency }, + shippingTotal: { value: 0, currency: data.currency }, + total: { value: 0, currency: data.currency }, + promotions: [], + metadata: data.metadata || {}, + }; + + cartsStore.push(newCart); + return newCart; +}; + +// Extended update type for internal use (shippingMethod/shippingTotal from resolved option) +type UpdateCartData = Carts.Request.UpdateCartBody & { + shippingMethod?: Carts.Model.Cart['shippingMethod']; + shippingTotal?: Models.Price.Price; +}; + +// Update a cart +export const updateCart = ( + params: Carts.Request.UpdateCartParams, + data: UpdateCartData, +): Carts.Model.Cart | undefined => { + const cartIndex = cartsStore.findIndex((cart) => cart.id === params.id); + if (cartIndex === -1) return undefined; + + const cart = cartsStore[cartIndex]!; + + // Merge metadata + const mergedMetadata = { + ...(cart.metadata || {}), + ...(data.metadata || {}), + }; + + // Extract addresses from metadata if they exist + const shippingAddressFromMetadata = mergedMetadata.shippingAddress as Models.Address.Address | undefined; + const billingAddressFromMetadata = mergedMetadata.billingAddress as Models.Address.Address | undefined; + + // Validate metadata.paymentMethod against known providers + if (mergedMetadata.paymentMethod && typeof mergedMetadata.paymentMethod === 'object') { + const metaPm = mergedMetadata.paymentMethod as Record; + if (metaPm.id && !getMockProviderById(metaPm.id as string)) { + delete mergedMetadata.paymentMethod; + } + } + + // Resolve payment method: from paymentMethodId (validated against providers), from metadata, or keep existing + const paymentMethod = data.paymentMethodId + ? (getPaymentMethodDisplay(data.paymentMethodId) as Carts.Model.PaymentMethod | undefined) + : (mapPaymentMethodFromMetadata(mergedMetadata) ?? cart.paymentMethod); + + const updatedCart: Carts.Model.Cart = { + ...cart, + name: data.name ?? cart.name, + regionId: data.regionId ?? cart.regionId, + email: data.email ?? cart.email, + notes: data.notes ?? cart.notes, + metadata: mergedMetadata, + shippingAddress: shippingAddressFromMetadata ?? cart.shippingAddress, + billingAddress: billingAddressFromMetadata ?? cart.billingAddress, + shippingMethod: data.shippingMethod ?? cart.shippingMethod, + shippingTotal: data.shippingTotal ?? cart.shippingTotal, + paymentMethod: paymentMethod ?? cart.paymentMethod, + updatedAt: formatDate(new Date()), + }; + + cartsStore[cartIndex] = updatedCart; + return updatedCart; +}; + +// Delete a cart +export const deleteCart = (params: Carts.Request.DeleteCartParams): boolean => { + const cartIndex = cartsStore.findIndex((cart) => cart.id === params.id); + if (cartIndex === -1) return false; + + cartsStore.splice(cartIndex, 1); + return true; +}; + +// Add item to cart +// Helper function to find active cart by customerId +export const findActiveCartByCustomerId = (customerId: string | undefined): Carts.Model.Cart | undefined => { + if (!customerId) return undefined; + return cartsStore.find((cart) => cart.customerId === customerId); +}; + +const matchesSku = (item: Carts.Model.CartItem, sku: string): boolean => item.sku === sku; + +export const addCartItem = ( + cartId: string, + data: Carts.Request.AddCartItemBody, + locale?: string, +): Carts.Model.Cart | undefined => { + const cartIndex = cartsStore.findIndex((cart) => cart.id === cartId); + if (cartIndex === -1) return undefined; + + let product: Products.Model.Product; + try { + product = mapProductBySku(data.sku, locale); + } catch { + return undefined; + } + + const cart = cartsStore[cartIndex]!; + + const existingIndex = cart.items.data.findIndex((item) => matchesSku(item, data.sku)); + + if (existingIndex !== -1) { + const item = cart.items.data[existingIndex]!; + const newQuantity = item.quantity + data.quantity; + item.quantity = newQuantity; + item.subtotal = { value: item.price.value * newQuantity, currency: cart.currency }; + item.total = { value: item.price.value * newQuantity, currency: cart.currency }; + if (data.metadata && Object.keys(data.metadata).length > 0) { + item.metadata = { ...(item.metadata || {}), ...data.metadata }; + } + } else { + const newItem = buildCartItemFromProduct(product, data.quantity, cart.currency, data.metadata || {}); + cart.items.data.push(newItem); + } + + cart.items.total = cart.items.data.length; + recalculateCartTotals(cart); + cart.updatedAt = formatDate(new Date()); + + return cart; +}; + +// Update cart item +export const updateCartItem = ( + params: Carts.Request.UpdateCartItemParams, + data: Carts.Request.UpdateCartItemBody, +): Carts.Model.Cart | undefined => { + const cartIndex = cartsStore.findIndex((cart) => cart.id === params.cartId); + if (cartIndex === -1) return undefined; + + const cart = cartsStore[cartIndex]!; + const itemIndex = cart.items.data.findIndex((item) => item.id === params.itemId); + if (itemIndex === -1) return undefined; + + const item = cart.items.data[itemIndex]!; + if (data.quantity !== undefined) { + if (data.quantity <= 0) { + cart.items.data.splice(itemIndex, 1); + cart.items.total = cart.items.data.length; + + recalculateCartTotals(cart); + cart.updatedAt = formatDate(new Date()); + return cart; + } else { + item.quantity = data.quantity; + item.subtotal = { value: item.price.value * data.quantity, currency: cart.currency }; + item.total = { value: item.price.value * data.quantity, currency: cart.currency }; + } + } + if (data.metadata !== undefined) { + item.metadata = data.metadata; + } + + recalculateCartTotals(cart); + cart.updatedAt = formatDate(new Date()); + + return cart; +}; + +// Remove cart item +export const removeCartItem = (params: Carts.Request.RemoveCartItemParams): Carts.Model.Cart | undefined => { + const cartIndex = cartsStore.findIndex((cart) => cart.id === params.cartId); + if (cartIndex === -1) return undefined; + + const cart = cartsStore[cartIndex]!; + const itemIndex = cart.items.data.findIndex((item) => item.id === params.itemId); + if (itemIndex === -1) return undefined; + + cart.items.data.splice(itemIndex, 1); + cart.items.total = cart.items.data.length; + recalculateCartTotals(cart); + cart.updatedAt = formatDate(new Date()); + + return cart; +}; + +// Apply promotion +export const applyPromotion = ( + params: Carts.Request.ApplyPromotionParams, + data: Carts.Request.ApplyPromotionBody, +): Carts.Model.Cart | undefined => { + const cartIndex = cartsStore.findIndex((cart) => cart.id === params.cartId); + if (cartIndex === -1) return undefined; + + const cart = cartsStore[cartIndex]!; + const promotion = PROMOTIONS.find((p) => p.code === data.code); + if (!promotion) return undefined; + + if (!cart.promotions) { + cart.promotions = []; + } + + // Check if promotion is already applied + if (cart.promotions.some((p) => p.id === promotion.id)) { + return cart; + } + + cart.promotions.push(promotion); + recalculateCartTotals(cart); + cart.updatedAt = formatDate(new Date()); + + return cart; +}; + +// Remove promotion +export const removePromotion = (params: Carts.Request.RemovePromotionParams): Carts.Model.Cart | undefined => { + const cartIndex = cartsStore.findIndex((cart) => cart.id === params.cartId); + if (cartIndex === -1) return undefined; + + const cart = cartsStore[cartIndex]!; + if (!cart.promotions) return cart; + + const promoIndex = cart.promotions.findIndex((p) => p.code === params.code); + if (promoIndex === -1) return cart; + + cart.promotions.splice(promoIndex, 1); + recalculateCartTotals(cart); + cart.updatedAt = formatDate(new Date()); + + return cart; +}; + +// Helper function to recalculate cart totals +const recalculateCartTotals = (cart: Carts.Model.Cart): void => { + let subtotal = 0; + for (const item of cart.items.data) { + subtotal += item.total.value; + } + + let discountTotal = 0; + if (cart.promotions) { + for (const promo of cart.promotions) { + if (promo.type === 'PERCENTAGE') { + discountTotal += (subtotal * Number(promo.value)) / 100; + } else if (promo.type === 'FIXED_AMOUNT') { + discountTotal += Number(promo.value); + } + } + } + + discountTotal = Math.min(discountTotal, subtotal); + + const shippingTotal = cart.shippingMethod?.total?.value || 0; + const hasFreeShipping = cart.promotions?.some((p) => p.type === 'FREE_SHIPPING'); + const actualShippingTotal = hasFreeShipping ? 0 : shippingTotal; + + const taxTotal = Math.round((subtotal - discountTotal) * 0.23 * 100) / 100; + const total = subtotal - discountTotal + actualShippingTotal + taxTotal; + + cart.subtotal = { value: subtotal, currency: cart.currency }; + cart.discountTotal = { value: Math.round(discountTotal * 100) / 100, currency: cart.currency }; + cart.shippingTotal = { value: actualShippingTotal, currency: cart.currency }; + cart.taxTotal = { value: taxTotal, currency: cart.currency }; + cart.total = { value: Math.round(total * 100) / 100, currency: cart.currency }; +}; diff --git a/packages/integrations/mocked/src/modules/carts/carts.service.ts b/packages/integrations/mocked/src/modules/carts/carts.service.ts new file mode 100644 index 000000000..7dc154bf5 --- /dev/null +++ b/packages/integrations/mocked/src/modules/carts/carts.service.ts @@ -0,0 +1,505 @@ +import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { Observable, forkJoin, of, switchMap, throwError } from 'rxjs'; + +import { Auth, Carts, Customers } from '@o2s/framework/modules'; + +import { getShippingOptionById } from '../checkout/checkout.mapper'; + +import { + addCartItem, + applyPromotion, + createCart, + deleteCart, + findActiveCartByCustomerId, + mapCart, + mapCarts, + removeCartItem, + removePromotion, + updateCart, + updateCartItem, +} from './carts.mapper'; +import { responseDelay } from '@/utils/delay'; + +@Injectable() +export class CartsService implements Carts.Service { + constructor( + private readonly authService: Auth.Service, + private readonly customersService: Customers.Service, + ) {} + + getCart( + params: Carts.Request.GetCartParams, + authorization: string | undefined, + ): Observable { + const cart = mapCart(params); + + // Customer carts require authorization + if (cart?.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to access this cart'); + } + const customerId = this.authService.getCustomerId(authorization); + if (cart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to access this cart'); + } + } + + return of(cart).pipe(responseDelay()); + } + + getCartList( + query: Carts.Request.GetCartListQuery, + authorization: string | undefined, + ): Observable { + // Guests cannot list carts — return empty to prevent enumerating other users' carts + if (!authorization) { + return of({ data: [], total: 0 }).pipe(responseDelay()); + } + + const customerId = this.authService.getCustomerId(authorization); + return of(mapCarts(query, customerId)).pipe(responseDelay()); + } + + createCart(data: Carts.Request.CreateCartBody, authorization: string | undefined): Observable { + // If no customerId provided but user is authenticated, use their ID + if (!data.customerId && authorization) { + const customerId = this.authService.getCustomerId(authorization); + if (customerId) { + data = { ...data, customerId }; + } + } + + const cart = createCart(data); + return of(cart).pipe(responseDelay()); + } + + updateCart( + params: Carts.Request.UpdateCartParams, + data: Carts.Request.UpdateCartBody, + authorization: string | undefined, + ): Observable { + const existingCart = mapCart({ id: params.id }); + + if (!existingCart) { + throw new NotFoundException('Cart not found'); + } + + // Customer carts require authorization + if (existingCart.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to update this cart'); + } + const customerId = this.authService.getCustomerId(authorization); + if (existingCart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to update this cart'); + } + } + + const cart = updateCart(params, data); + if (!cart) { + throw new NotFoundException('Cart not found'); + } + + return of(cart).pipe(responseDelay()); + } + + deleteCart(params: Carts.Request.DeleteCartParams, authorization: string | undefined): Observable { + const existingCart = mapCart({ id: params.id }); + + if (!existingCart) { + throw new NotFoundException('Cart not found'); + } + + // Customer carts require authorization + if (existingCart.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to delete this cart'); + } + const customerId = this.authService.getCustomerId(authorization); + if (existingCart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to delete this cart'); + } + } + + const deleted = deleteCart(params); + if (!deleted) { + throw new NotFoundException('Cart not found'); + } + + return of(void 0).pipe(responseDelay()); + } + + addCartItem(data: Carts.Request.AddCartItemBody, authorization: string | undefined): Observable { + let cartId: string; + let customerId: string | undefined; + + // Extract customerId if authenticated + if (authorization) { + customerId = this.authService.getCustomerId(authorization); + } + + // If cartId provided, use it + if (data.cartId) { + const existingCart = mapCart({ id: data.cartId }); + + if (!existingCart) { + throw new NotFoundException('Cart not found'); + } + + // Customer carts require authorization + if (existingCart.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to modify this cart'); + } + if (existingCart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to modify this cart'); + } + } + + cartId = data.cartId; + } else { + // No cartId provided - find or create cart + let cart: Carts.Model.Cart | undefined; + + if (customerId) { + // For authenticated users: find active cart or create one + cart = findActiveCartByCustomerId(customerId); + + if (!cart) { + // Create new active cart + if (!data.currency) { + throw new BadRequestException('Currency is required when creating a new cart'); + } + cart = createCart({ + customerId, + currency: data.currency, + regionId: data.regionId, + }); + } + } else { + // For guests: create new cart + if (!data.currency) { + throw new BadRequestException('Currency is required when creating a new cart'); + } + cart = createCart({ + currency: data.currency, + regionId: data.regionId, + }); + } + + cartId = cart.id; + } + + // Add item to cart + const updatedCart = addCartItem(cartId, data, data.locale); + if (!updatedCart) { + throw new NotFoundException('Cart or product not found'); + } + + return of(updatedCart).pipe(responseDelay()); + } + + updateCartItem( + params: Carts.Request.UpdateCartItemParams, + data: Carts.Request.UpdateCartItemBody, + authorization: string | undefined, + ): Observable { + const existingCart = mapCart({ id: params.cartId }); + + if (!existingCart) { + throw new NotFoundException('Cart not found'); + } + + // Customer carts require authorization + if (existingCart.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to modify this cart'); + } + const customerId = this.authService.getCustomerId(authorization); + if (existingCart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to modify this cart'); + } + } + + const cart = updateCartItem(params, data); + if (!cart) { + throw new NotFoundException('Cart or item not found'); + } + + return of(cart).pipe(responseDelay()); + } + + removeCartItem( + params: Carts.Request.RemoveCartItemParams, + authorization: string | undefined, + ): Observable { + const existingCart = mapCart({ id: params.cartId }); + + if (!existingCart) { + throw new NotFoundException('Cart not found'); + } + + // Customer carts require authorization + if (existingCart.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to modify this cart'); + } + const customerId = this.authService.getCustomerId(authorization); + if (existingCart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to modify this cart'); + } + } + + const cart = removeCartItem(params); + if (!cart) { + throw new NotFoundException('Cart or item not found'); + } + + return of(cart).pipe(responseDelay()); + } + + applyPromotion( + params: Carts.Request.ApplyPromotionParams, + data: Carts.Request.ApplyPromotionBody, + authorization: string | undefined, + ): Observable { + const existingCart = mapCart({ id: params.cartId }); + + if (!existingCart) { + throw new NotFoundException('Cart not found'); + } + + // Customer carts require authorization + if (existingCart.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to modify this cart'); + } + const customerId = this.authService.getCustomerId(authorization); + if (existingCart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to modify this cart'); + } + } + + const cart = applyPromotion(params, data); + if (!cart) { + throw new NotFoundException('Cart not found or invalid promotion code'); + } + + return of(cart).pipe(responseDelay()); + } + + removePromotion( + params: Carts.Request.RemovePromotionParams, + authorization: string | undefined, + ): Observable { + const existingCart = mapCart({ id: params.cartId }); + + if (!existingCart) { + throw new NotFoundException('Cart not found'); + } + + // Customer carts require authorization + if (existingCart.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to modify this cart'); + } + const customerId = this.authService.getCustomerId(authorization); + if (existingCart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to modify this cart'); + } + } + + const cart = removePromotion(params); + if (!cart) { + throw new NotFoundException('Cart not found'); + } + + return of(cart).pipe(responseDelay()); + } + + getCurrentCart(authorization: string | undefined): Observable { + let customerId: string | undefined; + + if (authorization) { + customerId = this.authService.getCustomerId(authorization); + } + + if (!customerId) { + // Guest users don't have a "current" cart + return of(undefined).pipe(responseDelay()); + } + + const cart = findActiveCartByCustomerId(customerId); + return of(cart).pipe(responseDelay()); + } + + prepareCheckout( + params: Carts.Request.PrepareCheckoutParams, + authorization: string | undefined, + ): Observable { + const cart = mapCart({ id: params.cartId }); + + if (!cart) { + throw new NotFoundException(`Cart with ID ${params.cartId} not found`); + } + + // Customer carts require authorization + if (cart.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to prepare checkout for this cart'); + } + const customerId = this.authService.getCustomerId(authorization); + if (cart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to prepare checkout for this cart'); + } + } + + // Validate cart has items + if (!cart.items || cart.items.data.length === 0) { + throw new BadRequestException('Cart must have items before preparing checkout'); + } + + return of(cart).pipe(responseDelay()); + } + + updateCartAddresses( + params: Carts.Request.UpdateCartAddressesParams, + data: Carts.Request.UpdateCartAddressesBody, + authorization: string | undefined, + ): Observable { + const existingCart = mapCart({ id: params.cartId }); + + if (!existingCart) { + return throwError(() => new NotFoundException('Cart not found')); + } + + // Customer carts require authorization + if (existingCart.customerId) { + if (!authorization) { + return throwError(() => new UnauthorizedException('Authentication required to update this cart')); + } + const customerId = this.authService.getCustomerId(authorization); + if (existingCart.customerId !== customerId) { + return throwError(() => new UnauthorizedException('Unauthorized to update this cart')); + } + } + + // Guest: require inline addresses when only IDs provided + const isGuest = !authorization; + const hasAddressIdsOnly = + (data.shippingAddressId && !data.shippingAddress) || (data.billingAddressId && !data.billingAddress); + if (isGuest && hasAddressIdsOnly) { + return throwError(() => new BadRequestException('Inline addresses required for guest checkout')); + } + + const resolveAddresses$ = (): Observable<{ + shippingAddress?: Carts.Model.Cart['shippingAddress']; + billingAddress?: Carts.Model.Cart['billingAddress']; + }> => { + if (!authorization) { + return of({ + shippingAddress: data.shippingAddress, + billingAddress: data.billingAddress, + }); + } + + const resolveAddress = (addr: Customers.Model.CustomerAddress | undefined, id: string) => + addr ? of(addr.address) : throwError(() => new NotFoundException(`Address ${id} not found`)); + + const shipping$ = + data.shippingAddressId && !data.shippingAddress + ? this.customersService + .getAddress({ id: data.shippingAddressId }, authorization) + .pipe(switchMap((addr) => resolveAddress(addr, data.shippingAddressId!))) + : of(data.shippingAddress); + + const billing$ = + data.billingAddressId && !data.billingAddress + ? this.customersService + .getAddress({ id: data.billingAddressId }, authorization) + .pipe(switchMap((addr) => resolveAddress(addr, data.billingAddressId!))) + : of(data.billingAddress); + + return forkJoin([shipping$, billing$]).pipe( + switchMap(([shippingAddress, billingAddress]) => + of({ + shippingAddress: shippingAddress ?? existingCart.shippingAddress, + billingAddress: billingAddress ?? existingCart.billingAddress, + }), + ), + ); + }; + + return resolveAddresses$().pipe( + switchMap(({ shippingAddress, billingAddress }) => { + const updateData: Carts.Request.UpdateCartBody = { + notes: data.notes, + email: data.email, + metadata: { + ...existingCart.metadata, + ...(shippingAddress && { shippingAddress }), + ...(billingAddress && { billingAddress }), + }, + }; + + const cart = updateCart({ id: params.cartId }, updateData); + if (!cart) { + return throwError(() => new NotFoundException('Cart not found')); + } + return of(cart); + }), + responseDelay(), + ); + } + + addShippingMethod( + params: Carts.Request.AddShippingMethodParams, + data: Carts.Request.AddShippingMethodBody, + authorization: string | undefined, + ): Observable { + const existingCart = mapCart({ id: params.cartId }); + + if (!existingCart) { + throw new NotFoundException('Cart not found'); + } + + // Customer carts require authorization + if (existingCart.customerId) { + if (!authorization) { + throw new UnauthorizedException('Authentication required to modify this cart'); + } + const customerId = this.authService.getCustomerId(authorization); + if (existingCart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to modify this cart'); + } + } + + // Validate cart has items + if (!existingCart.items || existingCart.items.data.length === 0) { + throw new BadRequestException('Cart must have items before adding shipping method'); + } + + const option = getShippingOptionById(data.shippingOptionId); + if (!option) { + throw new BadRequestException(`Shipping option ${data.shippingOptionId} not found`); + } + + const cart = updateCart( + { id: params.cartId }, + { + shippingMethod: { + id: option.id, + name: option.name, + description: option.description, + total: option.total, + }, + shippingTotal: option.total, + }, + ); + if (!cart) { + throw new NotFoundException('Cart not found'); + } + + return of(cart).pipe(responseDelay()); + } +} diff --git a/packages/integrations/mocked/src/modules/carts/index.ts b/packages/integrations/mocked/src/modules/carts/index.ts new file mode 100644 index 000000000..5b18b302c --- /dev/null +++ b/packages/integrations/mocked/src/modules/carts/index.ts @@ -0,0 +1,6 @@ +import { Carts } from '@o2s/framework/modules'; + +export { CartsService as Service } from './carts.service'; + +export import Request = Carts.Request; +export import Model = Carts.Model; diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts b/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts new file mode 100644 index 000000000..dc13c1bf3 --- /dev/null +++ b/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts @@ -0,0 +1,191 @@ +import { BadRequestException } from '@nestjs/common'; + +import { Carts, Checkout, Orders, Payments } from '@o2s/framework/modules'; + +import { getPaymentMethodDisplay } from '../payments/mocks/providers.mock'; + +export function mapCheckoutSummary( + cart: Carts.Model.Cart, + _paymentSession?: Payments.Model.PaymentSession, + locale?: string, +): Checkout.Model.CheckoutSummary { + if (!cart.shippingAddress) { + throw new BadRequestException('Shipping address is required for checkout summary'); + } + + if (!cart.billingAddress) { + throw new BadRequestException('Billing address is required for checkout summary'); + } + + if (!cart.shippingMethod) { + throw new BadRequestException('Shipping method is required for checkout summary'); + } + + if (!cart.paymentMethod) { + throw new BadRequestException('Payment method is required for checkout summary'); + } + + if (!cart.subtotal) { + throw new BadRequestException('Cart subtotal is required for checkout summary'); + } + + if (!cart.shippingTotal) { + throw new BadRequestException('Shipping total is required for checkout summary'); + } + + if (!cart.taxTotal) { + throw new BadRequestException('Tax total is required for checkout summary'); + } + + if (!cart.discountTotal) { + throw new BadRequestException('Discount total is required for checkout summary'); + } + + if (!cart.total) { + throw new BadRequestException('Cart total is required for checkout summary'); + } + + const shippingMethod = + locale && cart.shippingMethod + ? (getShippingOptionById(cart.shippingMethod.id, locale) ?? cart.shippingMethod) + : cart.shippingMethod; + + const paymentMethod = + locale && cart.paymentMethod + ? (getPaymentMethodDisplay(cart.paymentMethod.id, locale) ?? cart.paymentMethod) + : cart.paymentMethod; + + return { + cart: { + ...cart, + shippingMethod, + paymentMethod, + }, + shippingAddress: cart.shippingAddress, + billingAddress: cart.billingAddress, + shippingMethod, + paymentMethod, + totals: { + subtotal: cart.subtotal, + shipping: cart.shippingTotal, + tax: cart.taxTotal, + discount: cart.discountTotal, + total: cart.total, + }, + notes: cart.notes, + email: cart.email, + }; +} + +export function mapPlaceOrderResponse( + order: Orders.Model.Order, + paymentSession?: Payments.Model.PaymentSession, +): Checkout.Model.PlaceOrderResponse { + return { + order, + paymentRedirectUrl: paymentSession?.redirectUrl, + }; +} + +type Locale = 'en' | 'de' | 'pl'; + +const SHIPPING_OPTIONS_BY_LOCALE: Record< + Locale, + Array<{ + id: string; + name: string; + description: string; + total: { value: number; currency: string }; + subtotal: { value: number; currency: string }; + }> +> = { + en: [ + { + id: 'SHIP-001', + name: 'Standard Shipping', + description: '3-5 business days', + total: { value: 1000, currency: 'USD' }, + subtotal: { value: 1000, currency: 'USD' }, + }, + { + id: 'SHIP-002', + name: 'Express Shipping', + description: '1-2 business days', + total: { value: 2000, currency: 'USD' }, + subtotal: { value: 2000, currency: 'USD' }, + }, + { + id: 'SHIP-003', + name: 'Next Day Delivery', + description: 'Next business day', + total: { value: 3500, currency: 'USD' }, + subtotal: { value: 3500, currency: 'USD' }, + }, + ], + de: [ + { + id: 'SHIP-001', + name: 'Standardversand', + description: '3-5 Werktage', + total: { value: 1000, currency: 'USD' }, + subtotal: { value: 1000, currency: 'USD' }, + }, + { + id: 'SHIP-002', + name: 'Expressversand', + description: '1-2 Werktage', + total: { value: 2000, currency: 'USD' }, + subtotal: { value: 2000, currency: 'USD' }, + }, + { + id: 'SHIP-003', + name: 'Lieferung am nächsten Tag', + description: 'Nächster Werktag', + total: { value: 3500, currency: 'USD' }, + subtotal: { value: 3500, currency: 'USD' }, + }, + ], + pl: [ + { + id: 'SHIP-001', + name: 'Wysyłka standardowa', + description: '3-5 dni roboczych', + total: { value: 1000, currency: 'USD' }, + subtotal: { value: 1000, currency: 'USD' }, + }, + { + id: 'SHIP-002', + name: 'Wysyłka ekspresowa', + description: '1-2 dni robocze', + total: { value: 2000, currency: 'USD' }, + subtotal: { value: 2000, currency: 'USD' }, + }, + { + id: 'SHIP-003', + name: 'Dostawa następnego dnia', + description: 'Następny dzień roboczy', + total: { value: 3500, currency: 'USD' }, + subtotal: { value: 3500, currency: 'USD' }, + }, + ], +}; + +const normalizeLocale = (locale?: string): Locale => { + const lower = (locale ?? 'en').toLowerCase(); + if (lower.startsWith('de')) return 'de'; + if (lower.startsWith('pl')) return 'pl'; + return 'en'; +}; + +export function mapShippingOptions(locale?: string): Checkout.Model.ShippingOptions { + const options = SHIPPING_OPTIONS_BY_LOCALE[normalizeLocale(locale)] ?? SHIPPING_OPTIONS_BY_LOCALE.en; + return { + data: options as Orders.Model.ShippingMethod[], + total: options.length, + }; +} + +export function getShippingOptionById(id: string, locale?: string): Orders.Model.ShippingMethod | undefined { + const options = SHIPPING_OPTIONS_BY_LOCALE[normalizeLocale(locale)] ?? SHIPPING_OPTIONS_BY_LOCALE.en; + return options.find((opt) => opt.id === id) as Orders.Model.ShippingMethod | undefined; +} diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts new file mode 100644 index 000000000..78cfce7b7 --- /dev/null +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -0,0 +1,248 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Observable, of, throwError } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +import { Carts, Checkout, Payments } from '@o2s/framework/modules'; + +import { MOCKED_ORDERS, mapOrderFromCart } from '../orders/orders.mapper'; + +import { mapCheckoutSummary, mapPlaceOrderResponse, mapShippingOptions } from './checkout.mapper'; +import { responseDelay } from '@/utils/delay'; + +@Injectable() +export class CheckoutService implements Checkout.Service { + constructor( + private readonly cartsService: Carts.Service, + private readonly paymentsService: Payments.Service, + ) {} + + setAddresses( + params: Checkout.Request.SetAddressesParams, + data: Checkout.Request.SetAddressesBody, + authorization: string | undefined, + ): Observable { + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + if (!cart.items?.data?.length) { + return throwError(() => new BadRequestException('Cart must have items before checkout')); + } + + // Delegate to cart service + return this.cartsService.updateCartAddresses({ cartId: params.cartId }, data, authorization); + }), + responseDelay(), + ); + } + + setShippingMethod( + params: Checkout.Request.SetShippingMethodParams, + data: Checkout.Request.SetShippingMethodBody, + authorization: string | undefined, + ): Observable { + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + if (!cart.items || cart.items.data.length === 0) { + return throwError( + () => new BadRequestException('Cart must have items before adding shipping method'), + ); + } + + // Delegate to cart service + return this.cartsService.addShippingMethod( + { cartId: params.cartId }, + { shippingOptionId: data.shippingOptionId }, + authorization, + ); + }), + responseDelay(), + ); + } + + setPayment( + params: Checkout.Request.SetPaymentParams, + data: Checkout.Request.SetPaymentBody, + authorization: string | undefined, + ): Observable { + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + // Create payment session + return this.paymentsService + .createSession( + { + cartId: params.cartId, + providerId: data.providerId, + returnUrl: data.returnUrl, + cancelUrl: data.cancelUrl, + metadata: data.metadata, + }, + authorization, + ) + .pipe( + switchMap((session) => { + // Update cart with payment session ID and payment method, preserving existing metadata + return this.cartsService + .updateCart( + { id: params.cartId }, + { + metadata: { + ...cart.metadata, + paymentSessionId: session.id, + paymentMethod: { + id: session.providerId, + name: session.providerId, + type: 'OTHER', + }, + }, + }, + authorization, + ) + .pipe(map(() => session)); + }), + ); + }), + responseDelay(), + ); + } + + getCheckoutSummary( + params: Checkout.Request.GetCheckoutSummaryParams, + authorization: string | undefined, + ): Observable { + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + } + + const paymentSessionId = cart.metadata?.paymentSessionId as string | undefined; + + if (paymentSessionId) { + return this.paymentsService + .getSession({ id: paymentSessionId }, authorization) + .pipe(map((session) => mapCheckoutSummary(cart, session, params.locale))); + } + + return of(mapCheckoutSummary(cart, undefined, params.locale)); + }), + responseDelay(), + ); + } + + placeOrder( + params: Checkout.Request.PlaceOrderParams, + data: Checkout.Request.PlaceOrderBody | undefined, + authorization: string | undefined, + ): Observable { + return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( + switchMap((cart) => { + if (!cart) { + return throwError(() => new NotFoundException('Cart not found')); + } + + if (!cart.items?.data?.length) { + return throwError(() => new BadRequestException('Cart must have items before placing order')); + } + + // Validate required data + if (!cart.shippingAddress || !cart.billingAddress) { + return throwError(() => new BadRequestException('Shipping and billing addresses are required')); + } + + if (!cart.shippingMethod) { + return throwError(() => new BadRequestException('Shipping method is required')); + } + + const paymentSessionId = cart.metadata?.paymentSessionId as string | undefined; + if (!paymentSessionId) { + return throwError(() => new BadRequestException('Payment session is required')); + } + + // Get email (from request body or cart) + const email = data?.email || cart.email; + + // Create order from cart + const order = mapOrderFromCart(cart, email); + MOCKED_ORDERS.push(order); + + // Get payment session for redirect URL + return this.paymentsService + .getSession({ id: paymentSessionId }, authorization) + .pipe(map((session) => mapPlaceOrderResponse(order, session))); + }), + responseDelay(), + ); + } + + getShippingOptions( + params: Checkout.Request.GetShippingOptionsParams, + _authorization?: string, + ): Observable { + return of(mapShippingOptions(params.locale)).pipe(responseDelay()); + } + + completeCheckout( + params: Checkout.Request.CompleteCheckoutParams, + data: Checkout.Request.CompleteCheckoutBody, + authorization: string | undefined, + ): Observable { + // Set addresses first + return this.setAddresses( + { cartId: params.cartId }, + { + shippingAddressId: data.shippingAddressId, + shippingAddress: data.shippingAddress, + billingAddressId: data.billingAddressId, + billingAddress: data.billingAddress, + notes: data.notes, + email: data.email, + }, + authorization, + ).pipe( + // Set shipping method if provided + switchMap(() => + data.shippingMethodId + ? this.setShippingMethod( + { cartId: params.cartId }, + { shippingOptionId: data.shippingMethodId }, + authorization, + ) + : of(null), + ), + switchMap(() => + // Set payment + this.setPayment( + { cartId: params.cartId }, + { + providerId: data.paymentProviderId, + returnUrl: data.returnUrl, + cancelUrl: data.cancelUrl, + metadata: data.metadata, + }, + authorization, + ), + ), + switchMap(() => + // Place order + this.placeOrder( + { cartId: params.cartId }, + { + email: data.email, + }, + authorization, + ), + ), + ); + } +} diff --git a/packages/integrations/mocked/src/modules/checkout/index.ts b/packages/integrations/mocked/src/modules/checkout/index.ts new file mode 100644 index 000000000..995a0d3f8 --- /dev/null +++ b/packages/integrations/mocked/src/modules/checkout/index.ts @@ -0,0 +1,7 @@ +import { Checkout } from '@o2s/framework/modules'; + +export { CheckoutService as Service } from './checkout.service'; +export * as Mapper from './checkout.mapper'; + +export import Request = Checkout.Request; +export import Model = Checkout.Model; diff --git a/packages/integrations/mocked/src/modules/customers/customers.mapper.ts b/packages/integrations/mocked/src/modules/customers/customers.mapper.ts new file mode 100644 index 000000000..0dc1a8de5 --- /dev/null +++ b/packages/integrations/mocked/src/modules/customers/customers.mapper.ts @@ -0,0 +1,52 @@ +import { Customers } from '@o2s/framework/modules'; + +export function mapCustomerAddresses( + addresses: Customers.Model.CustomerAddress[], + limit = 10, + offset = 0, +): Customers.Model.CustomerAddresses { + const total = addresses.length; + const paginatedAddresses = addresses.slice(offset, offset + limit); + + return { + data: paginatedAddresses, + total, + }; +} + +export function mapCustomerAddress( + address: Customers.Model.CustomerAddress | undefined, +): Customers.Model.CustomerAddress | undefined { + return address; +} + +export function createCustomerAddress( + data: Customers.Request.CreateAddressBody, + customerId: string, +): Customers.Model.CustomerAddress { + const now = new Date().toISOString(); + const id = `addr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + + return { + id, + customerId, + label: data.label, + isDefault: data.isDefault ?? false, + address: data.address, + createdAt: now, + updatedAt: now, + }; +} + +export function updateCustomerAddress( + existing: Customers.Model.CustomerAddress, + data: Customers.Request.UpdateAddressBody, +): Customers.Model.CustomerAddress { + return { + ...existing, + label: data.label ?? existing.label, + isDefault: data.isDefault ?? existing.isDefault, + address: data.address ?? existing.address, + updatedAt: new Date().toISOString(), + }; +} diff --git a/packages/integrations/mocked/src/modules/customers/customers.service.ts b/packages/integrations/mocked/src/modules/customers/customers.service.ts new file mode 100644 index 000000000..3d10c270a --- /dev/null +++ b/packages/integrations/mocked/src/modules/customers/customers.service.ts @@ -0,0 +1,164 @@ +import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { Observable, of, throwError } from 'rxjs'; + +import { Auth, Customers } from '@o2s/framework/modules'; + +import { + createCustomerAddress, + mapCustomerAddress, + mapCustomerAddresses, + updateCustomerAddress, +} from './customers.mapper'; +import { getMockAddresses } from './mocks/addresses.mock'; +import { responseDelay } from '@/utils/delay'; + +@Injectable() +export class CustomersService implements Customers.Service { + private addresses: Customers.Model.CustomerAddress[] = [...getMockAddresses()]; + + constructor(private readonly authService: Auth.Service) {} + + getAddresses(authorization: string | undefined): Observable { + if (!authorization) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + const customerAddresses = this.addresses.filter((addr) => addr.customerId === customerId); + + return of(mapCustomerAddresses(customerAddresses)).pipe(responseDelay()); + } + + getAddress( + params: Customers.Request.GetAddressParams, + authorization: string | undefined, + ): Observable { + if (!authorization) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + const address = this.addresses.find((addr) => addr.id === params.id && addr.customerId === customerId); + + if (!address) { + return of(undefined).pipe(responseDelay()); + } + + return of(mapCustomerAddress(address)).pipe(responseDelay()); + } + + createAddress( + data: Customers.Request.CreateAddressBody, + authorization: string | undefined, + ): Observable { + if (!authorization) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + throw new UnauthorizedException('Invalid authentication'); + } + + // If this is set as default, unset other defaults + if (data.isDefault) { + this.addresses = this.addresses.map((addr) => + addr.customerId === customerId ? { ...addr, isDefault: false } : addr, + ); + } + + const newAddress = createCustomerAddress(data, customerId); + this.addresses.push(newAddress); + + return of(newAddress).pipe(responseDelay()); + } + + updateAddress( + params: Customers.Request.UpdateAddressParams, + data: Customers.Request.UpdateAddressBody, + authorization: string | undefined, + ): Observable { + if (!authorization) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + throw new UnauthorizedException('Invalid authentication'); + } + const index = this.addresses.findIndex((addr) => addr.id === params.id && addr.customerId === customerId); + + if (index === -1) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + + const existingAddress = this.addresses[index]; + if (!existingAddress) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + + // If setting as default, unset other defaults + if (data.isDefault) { + this.addresses = this.addresses.map((addr) => + addr.customerId === customerId && addr.id !== params.id ? { ...addr, isDefault: false } : addr, + ); + } + + const updatedAddress = updateCustomerAddress(existingAddress, data); + this.addresses[index] = updatedAddress; + + return of(updatedAddress).pipe(responseDelay()); + } + + deleteAddress(params: Customers.Request.DeleteAddressParams, authorization: string | undefined): Observable { + if (!authorization) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + const index = this.addresses.findIndex((addr) => addr.id === params.id && addr.customerId === customerId); + + if (index === -1) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + + this.addresses.splice(index, 1); + + return of(undefined).pipe(responseDelay()); + } + + setDefaultAddress( + params: Customers.Request.SetDefaultAddressParams, + authorization: string | undefined, + ): Observable { + if (!authorization) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + throw new UnauthorizedException('Invalid authentication'); + } + const index = this.addresses.findIndex((addr) => addr.id === params.id && addr.customerId === customerId); + + if (index === -1) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + + const existingAddress = this.addresses[index]; + if (!existingAddress) { + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); + } + + // Unset other defaults for this customer + this.addresses = this.addresses.map((addr) => + addr.customerId === customerId && addr.id !== params.id ? { ...addr, isDefault: false } : addr, + ); + + // Set this address as default + const updatedAddress: Customers.Model.CustomerAddress = { ...existingAddress, isDefault: true }; + this.addresses[index] = updatedAddress; + + return of(updatedAddress).pipe(responseDelay()); + } +} diff --git a/packages/integrations/mocked/src/modules/customers/index.ts b/packages/integrations/mocked/src/modules/customers/index.ts new file mode 100644 index 000000000..bbef93ca1 --- /dev/null +++ b/packages/integrations/mocked/src/modules/customers/index.ts @@ -0,0 +1,8 @@ +import { Customers } from '@o2s/framework/modules'; + +export { CustomersService as Service } from './customers.service'; +export * as Mapper from './customers.mapper'; +export * as Mocks from './mocks/addresses.mock'; + +export import Request = Customers.Request; +export import Model = Customers.Model; diff --git a/packages/integrations/mocked/src/modules/customers/mocks/addresses.mock.ts b/packages/integrations/mocked/src/modules/customers/mocks/addresses.mock.ts new file mode 100644 index 000000000..28126b24d --- /dev/null +++ b/packages/integrations/mocked/src/modules/customers/mocks/addresses.mock.ts @@ -0,0 +1,68 @@ +import { Customers, Models } from '@o2s/framework/modules'; + +const MOCK_ADDRESSES: Customers.Model.CustomerAddress[] = [ + { + id: 'addr-001', + customerId: 'customer-001', + label: 'Home', + isDefault: true, + address: { + country: 'US', + streetName: 'Main Street', + streetNumber: '123', + apartment: 'Apt 4B', + city: 'New York', + postalCode: '10001', + email: 'customer@example.com', + phone: '+1-555-0100', + } as Models.Address.Address, + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + }, + { + id: 'addr-002', + customerId: 'customer-001', + label: 'Work', + isDefault: false, + address: { + country: 'US', + streetName: 'Business Avenue', + streetNumber: '456', + city: 'New York', + postalCode: '10002', + email: 'customer@example.com', + phone: '+1-555-0101', + } as Models.Address.Address, + createdAt: '2024-02-01T10:00:00Z', + updatedAt: '2024-02-01T10:00:00Z', + }, + { + id: 'addr-003', + customerId: 'customer-002', + label: 'Home', + isDefault: true, + address: { + country: 'CA', + streetName: 'Maple Drive', + streetNumber: '789', + city: 'Toronto', + postalCode: 'M5H 2N2', + email: 'customer2@example.com', + phone: '+1-416-555-0102', + } as Models.Address.Address, + createdAt: '2024-01-20T10:00:00Z', + updatedAt: '2024-01-20T10:00:00Z', + }, +]; + +export function getMockAddresses(): Customers.Model.CustomerAddress[] { + return MOCK_ADDRESSES; +} + +export function getMockAddressById(id: string): Customers.Model.CustomerAddress | undefined { + return MOCK_ADDRESSES.find((addr) => addr.id === id); +} + +export function getMockAddressesByCustomerId(customerId: string): Customers.Model.CustomerAddress[] { + return MOCK_ADDRESSES.filter((addr) => addr.customerId === customerId); +} diff --git a/packages/integrations/mocked/src/modules/index.ts b/packages/integrations/mocked/src/modules/index.ts index 0e19618ee..994250298 100644 --- a/packages/integrations/mocked/src/modules/index.ts +++ b/packages/integrations/mocked/src/modules/index.ts @@ -11,4 +11,8 @@ export * as Cache from './cache'; export * as BillingAccounts from './billing-accounts'; export * as Search from './search'; export * as Products from './products'; +export * as Carts from './carts'; +export * as Customers from './customers'; +export * as Payments from './payments'; +export * as Checkout from './checkout'; export * as Auth from './auth'; diff --git a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts index 562632716..221cbef8f 100644 --- a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts @@ -1,4 +1,6 @@ -import { Models, Orders, Products } from '@o2s/framework/modules'; +import { BadRequestException } from '@nestjs/common'; + +import { Carts, Models, Orders, Products } from '@o2s/framework/modules'; // Product data for generating random orders const PRODUCT_DATA = [ @@ -407,7 +409,7 @@ const generateOrders = (count: number, getRandomDate: () => Date): Orders.Model. }; // Generate 1000 orders spanning the past 2 years -const MOCKED_ORDERS = [ +export const MOCKED_ORDERS: Orders.Model.Order[] = [ ...generateOrders(100, getRandomDatePastYear), ...generateOrders(50, getRandomDatePastMonth), ...generateOrders(400, getRandomDateYearBefore), @@ -501,3 +503,56 @@ export const mapOrders = (options: Orders.Request.GetOrderListQuery, customerId: total: filteredOrders.length, }; }; + +export function mapOrderFromCart(cart: Carts.Model.Cart, email?: string): Orders.Model.Order { + const now = new Date(); + const orderId = `ORD-${Date.now()}`; + + // Convert cart items to order items + const orderItems: Orders.Model.OrderItem[] = cart.items.data.map((item, index) => ({ + id: `ITEM-${index.toString().padStart(3, '0')}`, + productId: item.product.id, + quantity: item.quantity, + price: item.price, + total: item.total, + subtotal: item.subtotal, + discountTotal: item.discountTotal, + unit: item.unit, + currency: item.currency, + product: item.product, + })); + + if (!cart.shippingMethod) { + throw new BadRequestException('Shipping method is required to create order from cart'); + } + + if (!cart.shippingTotal) { + throw new BadRequestException('Shipping total is required to create order from cart'); + } + + return { + id: orderId, + customerId: cart.customerId, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + paymentDueDate: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days from now + total: cart.total, + subtotal: cart.subtotal, + shippingTotal: cart.shippingTotal, + shippingSubtotal: cart.shippingTotal, + discountTotal: cart.discountTotal, + tax: cart.taxTotal, + currency: cart.currency, + paymentStatus: 'PENDING', + status: 'PENDING', + items: { + data: orderItems, + total: orderItems.length, + }, + shippingAddress: cart.shippingAddress, + billingAddress: cart.billingAddress, + shippingMethods: [cart.shippingMethod], + customerComment: cart.notes, + email: email ?? cart.email, + }; +} diff --git a/packages/integrations/mocked/src/modules/payments/index.ts b/packages/integrations/mocked/src/modules/payments/index.ts new file mode 100644 index 000000000..291b5ec4f --- /dev/null +++ b/packages/integrations/mocked/src/modules/payments/index.ts @@ -0,0 +1,8 @@ +import { Payments } from '@o2s/framework/modules'; + +export { PaymentsService as Service } from './payments.service'; +export * as Mapper from './payments.mapper'; +export * as Mocks from './mocks/providers.mock'; + +export import Request = Payments.Request; +export import Model = Payments.Model; diff --git a/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts b/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts new file mode 100644 index 000000000..8d880aa1b --- /dev/null +++ b/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts @@ -0,0 +1,85 @@ +import { Payments } from '@o2s/framework/modules'; + +type Locale = 'en' | 'de' | 'pl'; + +const PROVIDERS_BY_LOCALE: Record> = { + en: [ + { + id: 'stripe', + name: 'Stripe', + type: 'STRIPE', + isEnabled: true, + requiresRedirect: true, + config: { publishableKey: 'pk_test_mock' }, + }, + { id: 'paypal', name: 'PayPal', type: 'PAYPAL', isEnabled: true, requiresRedirect: true, config: {} }, + { id: 'system', name: 'System Payment', type: 'SYSTEM', isEnabled: true, requiresRedirect: false, config: {} }, + ], + de: [ + { + id: 'stripe', + name: 'Stripe', + type: 'STRIPE', + isEnabled: true, + requiresRedirect: true, + config: { publishableKey: 'pk_test_mock' }, + }, + { id: 'paypal', name: 'PayPal', type: 'PAYPAL', isEnabled: true, requiresRedirect: true, config: {} }, + { id: 'system', name: 'Systemzahlung', type: 'SYSTEM', isEnabled: true, requiresRedirect: false, config: {} }, + ], + pl: [ + { + id: 'stripe', + name: 'Stripe', + type: 'STRIPE', + isEnabled: true, + requiresRedirect: true, + config: { publishableKey: 'pk_test_mock' }, + }, + { id: 'paypal', name: 'PayPal', type: 'PAYPAL', isEnabled: true, requiresRedirect: true, config: {} }, + { + id: 'system', + name: 'Płatność systemowa', + type: 'SYSTEM', + isEnabled: true, + requiresRedirect: false, + config: {}, + }, + ], +}; + +const normalizeLocale = (locale?: string): Locale => { + const lower = (locale ?? 'en').toLowerCase(); + if (lower.startsWith('de')) return 'de'; + if (lower.startsWith('pl')) return 'pl'; + return 'en'; +}; + +export function getMockProviders(locale?: string): Payments.Model.PaymentProvider[] { + return (PROVIDERS_BY_LOCALE[normalizeLocale(locale)] ?? PROVIDERS_BY_LOCALE.en) as Payments.Model.PaymentProvider[]; +} + +export function getMockProviderById(id: string, locale?: string): Payments.Model.PaymentProvider | undefined { + return getMockProviders(locale).find((provider) => provider.id === id); +} + +/** Map provider type to cart PaymentMethodType. */ +const providerTypeToPaymentMethodType = (t: string): 'CREDIT_CARD' | 'PAYPAL' | 'BANK_TRANSFER' | 'OTHER' => { + if (t === 'PAYPAL') return 'PAYPAL'; + if (t === 'STRIPE') return 'CREDIT_CARD'; + return 'OTHER'; +}; + +/** Return display info for payment method by provider id (for checkout summary localization). */ +export function getPaymentMethodDisplay( + providerId: string, + locale?: string, +): { id: string; name: string; type: 'CREDIT_CARD' | 'PAYPAL' | 'BANK_TRANSFER' | 'OTHER' } | undefined { + const provider = getMockProviderById(providerId, locale); + if (!provider) return undefined; + return { + id: provider.id, + name: provider.name, + type: providerTypeToPaymentMethodType(provider.type), + }; +} diff --git a/packages/integrations/mocked/src/modules/payments/payments.mapper.ts b/packages/integrations/mocked/src/modules/payments/payments.mapper.ts new file mode 100644 index 000000000..149ffa357 --- /dev/null +++ b/packages/integrations/mocked/src/modules/payments/payments.mapper.ts @@ -0,0 +1,51 @@ +import { Payments } from '@o2s/framework/modules'; + +export function mapPaymentProviders(providers: Payments.Model.PaymentProvider[]): Payments.Model.PaymentProviders { + const total = providers.length; + + return { + data: providers, + total, + }; +} + +export function mapPaymentSession( + session: Payments.Model.PaymentSession | undefined, +): Payments.Model.PaymentSession | undefined { + return session; +} + +export function createPaymentSession( + data: Payments.Request.CreateSessionBody, + provider: Payments.Model.PaymentProvider, +): Payments.Model.PaymentSession { + const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 minutes + const id = `ps_${Date.now()}`; + + const redirectUrl = provider.requiresRedirect + ? `https://checkout.example.com/payment/${id}?returnUrl=${encodeURIComponent(data.returnUrl)}` + : undefined; + + return { + id, + cartId: data.cartId, + providerId: data.providerId, + status: 'PENDING', + redirectUrl, + expiresAt, + metadata: data.metadata, + }; +} + +export function updatePaymentSession( + existing: Payments.Model.PaymentSession, + data: Payments.Request.UpdateSessionBody, +): Payments.Model.PaymentSession { + return { + ...existing, + redirectUrl: data.returnUrl + ? `https://checkout.example.com/payment/${existing.id}?returnUrl=${encodeURIComponent(data.returnUrl)}` + : existing.redirectUrl, + metadata: data.metadata ?? existing.metadata, + }; +} diff --git a/packages/integrations/mocked/src/modules/payments/payments.service.ts b/packages/integrations/mocked/src/modules/payments/payments.service.ts new file mode 100644 index 000000000..5ea9c8801 --- /dev/null +++ b/packages/integrations/mocked/src/modules/payments/payments.service.ts @@ -0,0 +1,88 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Observable, of, throwError } from 'rxjs'; + +import { Payments } from '@o2s/framework/modules'; + +import { getMockProviderById, getMockProviders } from './mocks/providers.mock'; +import { createPaymentSession, mapPaymentProviders, mapPaymentSession, updatePaymentSession } from './payments.mapper'; +import { responseDelay } from '@/utils/delay'; + +@Injectable() +export class PaymentsService implements Payments.Service { + private sessions: Payments.Model.PaymentSession[] = []; + + getProviders( + params: Payments.Request.GetProvidersParams, + _authorization: string | undefined, + ): Observable { + const providers = getMockProviders(params.locale); + return of(mapPaymentProviders(providers)).pipe(responseDelay()); + } + + createSession( + data: Payments.Request.CreateSessionBody, + _authorization: string | undefined, + ): Observable { + const provider = getMockProviderById(data.providerId, 'en'); + + if (!provider) { + return throwError(() => new NotFoundException(`Payment provider with ID ${data.providerId} not found`)); + } + + // Cancel any existing sessions for this cart + this.sessions = this.sessions.filter((s) => s.cartId !== data.cartId || s.status !== 'PENDING'); + + const session = createPaymentSession(data, provider); + this.sessions.push(session); + + return of(session).pipe(responseDelay()); + } + + getSession( + params: Payments.Request.GetSessionParams, + _authorization: string | undefined, + ): Observable { + const session = this.sessions.find((s) => s.id === params.id); + return of(mapPaymentSession(session)).pipe(responseDelay()); + } + + updateSession( + params: Payments.Request.UpdateSessionParams, + data: Payments.Request.UpdateSessionBody, + _authorization: string | undefined, + ): Observable { + const index = this.sessions.findIndex((s) => s.id === params.id); + + if (index === -1) { + return throwError(() => new NotFoundException(`Payment session with ID ${params.id} not found`)); + } + + const existingSession = this.sessions[index]; + if (!existingSession) { + return throwError(() => new NotFoundException(`Payment session with ID ${params.id} not found`)); + } + + const updatedSession = updatePaymentSession(existingSession, data); + this.sessions[index] = updatedSession; + + return of(updatedSession).pipe(responseDelay()); + } + + cancelSession(params: Payments.Request.CancelSessionParams, _authorization: string | undefined): Observable { + const index = this.sessions.findIndex((s) => s.id === params.id); + + if (index === -1) { + return throwError(() => new NotFoundException(`Payment session with ID ${params.id} not found`)); + } + + const existingSession = this.sessions[index]; + if (!existingSession) { + return throwError(() => new NotFoundException(`Payment session with ID ${params.id} not found`)); + } + + const cancelledSession: Payments.Model.PaymentSession = { ...existingSession, status: 'CANCELLED' }; + this.sessions[index] = cancelledSession; + + return of(undefined).pipe(responseDelay()); + } +} diff --git a/packages/integrations/mocked/src/modules/products/products.mapper.ts b/packages/integrations/mocked/src/modules/products/products.mapper.ts index a8eebcaa9..7a9636d7a 100644 --- a/packages/integrations/mocked/src/modules/products/products.mapper.ts +++ b/packages/integrations/mocked/src/modules/products/products.mapper.ts @@ -90,6 +90,21 @@ export const mapProduct = (id: string, locale?: string, variantId?: string): Pro return product; }; +export const mapProductBySku = (sku: string, locale?: string): Products.Model.Product => { + let productsSource = MOCK_PRODUCTS_EN; + if (locale === 'pl') { + productsSource = MOCK_PRODUCTS_PL; + } else if (locale === 'de') { + productsSource = MOCK_PRODUCTS_DE; + } + + const product = productsSource.find((product) => product.sku === sku); + if (!product) { + throw new Error(`Product with SKU ${sku} not found`); + } + return product; +}; + export const mapProducts = (options: Products.Request.GetProductListQuery): Products.Model.Products => { const { sort, locale, offset = 0, limit = 12 } = options; diff --git a/packages/integrations/mocked/src/modules/products/products.service.ts b/packages/integrations/mocked/src/modules/products/products.service.ts index 42562c0cb..a0d31099d 100644 --- a/packages/integrations/mocked/src/modules/products/products.service.ts +++ b/packages/integrations/mocked/src/modules/products/products.service.ts @@ -8,15 +8,21 @@ import { responseDelay } from '@/utils/delay'; @Injectable() export class ProductsService implements Products.Service { - getProductList(query: Products.Request.GetProductListQuery): Observable { + getProductList( + query: Products.Request.GetProductListQuery, + _authorization?: string, + ): Observable { return of(mapProducts(query)).pipe(responseDelay()); } - getProduct(params: Products.Request.GetProductParams): Observable { + getProduct(params: Products.Request.GetProductParams, _authorization?: string): Observable { return of(mapProduct(params.id, params.locale, params.variantId)).pipe(responseDelay()); } - getRelatedProductList(params: Products.Request.GetRelatedProductListParams): Observable { + getRelatedProductList( + params: Products.Request.GetRelatedProductListParams, + _authorization?: string, + ): Observable { return of(mapRelatedProducts(params)).pipe(responseDelay()); } } diff --git a/scripts/generate-postman-collection.mjs b/scripts/generate-postman-collection.mjs new file mode 100644 index 000000000..63988d0b4 --- /dev/null +++ b/scripts/generate-postman-collection.mjs @@ -0,0 +1,636 @@ +import fs from 'fs'; + +// Collection structure +const collection = { + info: { + name: 'O2S API', + description: 'Complete API collection for O2S API Harmonization app', + schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', + }, + auth: { + type: 'bearer', + bearer: [ + { + key: 'token', + value: '{{authToken}}', + type: 'string', + }, + ], + }, + variable: [ + { key: 'baseUrl', value: 'http://localhost:3001', type: 'string' }, + { key: 'apiPrefix', value: 'api', type: 'string' }, + { key: 'authToken', value: 'Bearer mock-token', type: 'string' }, + { key: 'locale', value: 'en', type: 'string' }, + { key: 'currency', value: 'EUR', type: 'string' }, + { key: 'clientTimezone', value: 'UTC', type: 'string' }, + { key: 'userId', value: 'admin-1', type: 'string' }, + { key: 'orderId', value: 'ORD-001', type: 'string' }, + { key: 'ticketId', value: 'EL-465-920-678', type: 'string' }, + { key: 'invoiceId', value: '56698/06/2025', type: 'string' }, + { key: 'cartId', value: 'CART-001', type: 'string' }, + { key: 'productId', value: 'PRD-004', type: 'string' }, + { key: 'variantId', value: 'PRD-004-V1', type: 'string' }, + { key: 'sku', value: 'RH-2400X-PRO', type: 'string' }, + { key: 'notificationId', value: 'NOT-123-456', type: 'string' }, + { key: 'articleId', value: 'article-1', type: 'string' }, + { key: 'categoryId', value: '1', type: 'string' }, + { key: 'orgId', value: 'org-001', type: 'string' }, + { key: 'addressId', value: 'addr-001', type: 'string' }, + { key: 'sessionId', value: '1', type: 'string' }, + { key: 'billingAccountId', value: 'BA-003', type: 'string' }, + { key: 'serviceId', value: 'SRV-001', type: 'string' }, + { key: 'assetId', value: 'AST-003', type: 'string' }, + { key: 'resourceId', value: 'SRV-001', type: 'string' }, + { key: 'itemId', value: '1', type: 'string' }, + { key: 'promotionCode', value: 'SAVE10', type: 'string' }, + { key: 'providerId', value: 'stripe', type: 'string' }, + { key: 'shippingOptionId', value: 'SHIP-001', type: 'string' }, + { key: 'regionId', value: 'reg-001', type: 'string' }, + { key: 'slug', value: 'home', type: 'string' }, + { key: 'cmsEntryId', value: '1', type: 'string' }, + { key: 'referrer', value: 'http://localhost:3000', type: 'string' }, + { key: 'customerId', value: 'cust-001', type: 'string' }, + ], + item: [], +}; + +// Helper function to create a request +function createRequest(name, method, path, query = [], body = null, description = '') { + // Build path array with proper variable handling - use {{variableName}} format + const pathParts = path.split('/').filter((p) => p); + const urlPath = ['{{apiPrefix}}']; + + pathParts.forEach((part) => { + if (part.startsWith(':')) { + const varName = part.substring(1); + // Use {{variableName}} format directly in path + urlPath.push(`{{${varName}}}`); + } else { + urlPath.push(part); + } + }); + + // Filter out locale query parameter since we always use x-locale header + const filteredQuery = query.filter((q) => q.key !== 'locale'); + + const url = { + raw: `{{baseUrl}}/${urlPath.join('/')}`.replace(/\/+/g, '/'), + host: ['{{baseUrl}}'], + path: urlPath, + query: filteredQuery.map((q) => ({ key: q.key, value: q.value || `{{${q.key}}}` })), + }; + + const request = { + name, + request: { + method, + header: [ + { key: 'x-locale', value: '{{locale}}', type: 'text' }, + { key: 'x-currency', value: '{{currency}}', type: 'text' }, + { key: 'x-client-timezone', value: '{{clientTimezone}}', type: 'text' }, + ], + url, + auth: null, // Inherit from parent + }, + }; + + if (description) { + request.request.description = description; + } + + if (body) { + request.request.body = { + mode: 'raw', + raw: JSON.stringify(body, null, 2), + options: { + raw: { + language: 'json', + }, + }, + }; + request.request.header.push({ key: 'Content-Type', value: 'application/json', type: 'text' }); + } + + return request; +} + +// Helper to create a folder +function createFolder(name, items) { + return { + name, + item: items, + auth: null, // Inherit from parent + }; +} + +// Framework Modules +const frameworkModules = []; + +// CMS Module +const cmsRequests = [ + createRequest('Get Entry', 'GET', '/cms/get-entry', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Entries', 'GET', '/cms/get-entries', [ + { key: 'locale', value: '{{locale}}' }, + { key: 'type', value: 'page' }, + ]), + createRequest('Get Page', 'GET', '/cms/page', [ + { key: 'slug', value: '{{slug}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Pages', 'GET', '/cms/pages', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Get Login Page', 'GET', '/cms/login-page', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Get Not Found Page', 'GET', '/cms/not-found-page', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Get Header', 'GET', '/cms/header', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Footer', 'GET', '/cms/footer', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get App Config', 'GET', '/cms/app-config', [ + { key: 'referrer', value: '{{referrer}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get FAQ Block', 'GET', '/cms/blocks/faq', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Ticket List Block', 'GET', '/cms/blocks/ticket-list', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Ticket Details Block', 'GET', '/cms/blocks/ticket-details', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Notification List Block', 'GET', '/cms/blocks/notification-list', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Notification Details Block', 'GET', '/cms/blocks/notification-details', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Article List Block', 'GET', '/cms/blocks/article-list', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Article Details Block', 'GET', '/cms/blocks/article-details', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Invoice List Block', 'GET', '/cms/blocks/invoice-list', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Invoice Details Block', 'GET', '/cms/blocks/invoice-details', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Resource List Block', 'GET', '/cms/blocks/resource-list', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Resource Details Block', 'GET', '/cms/blocks/resource-details', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get User Account Block', 'GET', '/cms/blocks/user-account', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Service List Block', 'GET', '/cms/blocks/service-list', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Service Details Block', 'GET', '/cms/blocks/service-details', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Featured Service List Block', 'GET', '/cms/blocks/featured-service-list', [ + { key: 'id', value: '{{cmsEntryId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), +]; +frameworkModules.push(createFolder('CMS', cmsRequests)); + +// Tickets Module +const ticketsRequests = [ + createRequest('Get Ticket List', 'GET', '/tickets', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Ticket', 'GET', '/tickets/:ticketId', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Create Ticket', 'POST', '/tickets', [], { + title: 'Sample Ticket', + description: 'Ticket description', + type: 1, + }), +]; +frameworkModules.push(createFolder('Tickets', ticketsRequests)); + +// Orders Module +const ordersRequests = [ + createRequest('Get Order List', 'GET', '/orders', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Order', 'GET', '/orders/:orderId', [{ key: 'locale', value: '{{locale}}' }]), +]; +frameworkModules.push(createFolder('Orders', ordersRequests)); + +// Invoices Module +const invoicesRequests = [ + createRequest('Get Invoice List', 'GET', '/invoices', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Invoice', 'GET', '/invoices/:invoiceId'), + createRequest('Get Invoice PDF', 'GET', '/invoices/:invoiceId/pdf'), +]; +frameworkModules.push(createFolder('Invoices', invoicesRequests)); + +// Carts Module +const cartsRequests = [ + createRequest('Get Cart List', 'GET', '/carts', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Current Cart', 'GET', '/carts/current'), + createRequest('Get Cart', 'GET', '/carts/:cartId'), + createRequest('Create Cart', 'POST', '/carts', [], { currency: '{{currency}}', regionId: '{{regionId}}' }), + createRequest('Update Cart', 'PATCH', '/carts/:cartId', [], { name: 'Updated Cart' }), + createRequest('Delete Cart', 'DELETE', '/carts/:cartId'), + createRequest('Add Cart Item', 'POST', '/carts/items', [], { + cartId: '{{cartId}}', + sku: '{{sku}}', + quantity: 1, + currency: '{{currency}}', + }), + createRequest('Update Cart Item', 'PATCH', '/carts/:cartId/items/:itemId', [], { quantity: 2 }), + createRequest('Remove Cart Item', 'DELETE', '/carts/:cartId/items/:itemId'), + createRequest('Apply Promotion', 'POST', '/carts/:cartId/promotions', [], { code: '{{promotionCode}}' }), + createRequest('Remove Promotion', 'DELETE', '/carts/:cartId/promotions/:promotionCode'), +]; +frameworkModules.push(createFolder('Carts', cartsRequests)); + +// Checkout Module +const checkoutRequests = [ + createRequest('Get Shipping Options', 'GET', '/checkout/:cartId/shipping-options', [ + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Checkout Summary', 'GET', '/checkout/:cartId/summary', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Set Addresses', 'POST', '/checkout/:cartId/addresses', [], { + shippingAddress: { + streetName: 'Main Street', + streetNumber: '123', + city: 'New York', + country: 'US', + postalCode: '10001', + }, + }), + createRequest('Set Shipping Method', 'POST', '/checkout/:cartId/shipping-method', [], { + shippingOptionId: '{{shippingOptionId}}', + }), + createRequest('Set Payment', 'POST', '/checkout/:cartId/payment', [], { + providerId: '{{providerId}}', + returnUrl: 'http://localhost:3000/return', + }), + createRequest('Place Order', 'POST', '/checkout/:cartId/place-order', [], { email: 'user@example.com' }), + createRequest('Complete Checkout', 'POST', '/checkout/:cartId/complete', [], { + paymentProviderId: '{{providerId}}', + returnUrl: 'http://localhost:3000/return', + shippingAddress: { + streetName: 'Main Street', + streetNumber: '123', + city: 'New York', + country: 'US', + postalCode: '10001', + }, + billingAddress: { + streetName: 'Main Street', + streetNumber: '123', + city: 'New York', + country: 'US', + postalCode: '10001', + }, + }), +]; +frameworkModules.push(createFolder('Checkout', checkoutRequests)); + +// Users Module +const usersRequests = [ + createRequest('Get Current User', 'GET', '/users/me'), + createRequest('Get User', 'GET', '/users/:userId'), + createRequest('Get Current User Customers', 'GET', '/users/me/customers'), + createRequest('Get Current User Customer', 'GET', '/users/me/customers/:customerId'), + createRequest('Update Current User', 'PATCH', '/users/me', [], { firstName: 'John', lastName: 'Doe' }), + createRequest('Update User', 'PATCH', '/users/:userId', [], { firstName: 'John', lastName: 'Doe' }), + createRequest('Delete Current User', 'DELETE', '/users/me'), + createRequest('Delete User', 'DELETE', '/users/:userId'), +]; +frameworkModules.push(createFolder('Users', usersRequests)); + +// Products Module +const productsRequests = [ + createRequest('Get Product List', 'GET', '/products', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Product', 'GET', '/products/:productId', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Get Related Products', 'GET', '/products/:productId/variants/:variantId/related-products', [ + { key: 'type', value: 'COMPATIBLE' }, + { key: 'locale', value: '{{locale}}' }, + { key: 'limit', value: '10' }, + ]), +]; +frameworkModules.push(createFolder('Products', productsRequests)); + +// Notifications Module +const notificationsRequests = [ + createRequest('Get Notification List', 'GET', '/notifications', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Notification', 'GET', '/notifications/:notificationId', [ + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Mark Notification As', 'POST', '/notifications', [], { id: '{{notificationId}}', status: 'read' }), +]; +frameworkModules.push(createFolder('Notifications', notificationsRequests)); + +// Articles Module +const articlesRequests = [ + createRequest('Get Article List', 'GET', '/articles', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Article', 'GET', '/articles/:articleId', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Search Articles', 'GET', '/articles/search', [ + { key: 'query', value: 'search term' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Category List', 'GET', '/articles/categories', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Get Category', 'GET', '/articles/categories/:categoryId', [{ key: 'locale', value: '{{locale}}' }]), +]; +frameworkModules.push(createFolder('Articles', articlesRequests)); + +// Organizations Module +const organizationsRequests = [ + createRequest('Get Organization List', 'GET', '/organizations', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + ]), + createRequest('Get Organization', 'GET', '/organizations/:orgId'), + createRequest('Check Membership', 'GET', '/organizations/membership/:orgId/:userId'), +]; +frameworkModules.push(createFolder('Organizations', organizationsRequests)); + +// Customers Module +const customersRequests = [ + createRequest('Get Address List', 'GET', '/customers/addresses'), + createRequest('Get Address', 'GET', '/customers/addresses/:addressId'), + createRequest('Create Address', 'POST', '/customers/addresses', [], { + address: { + streetName: 'Main Street', + streetNumber: '123', + city: 'New York', + country: 'US', + postalCode: '10001', + }, + isDefault: false, + }), + createRequest('Update Address', 'PATCH', '/customers/addresses/:addressId', [], { + address: { + streetName: 'Business Avenue', + streetNumber: '456', + city: 'New York', + country: 'US', + postalCode: '10002', + }, + }), + createRequest('Delete Address', 'DELETE', '/customers/addresses/:addressId'), + createRequest('Set Default Address', 'POST', '/customers/addresses/:addressId/default'), +]; +frameworkModules.push(createFolder('Customers', customersRequests)); + +// Payments Module +const paymentsRequests = [ + createRequest('Get Providers', 'GET', '/payments/providers', [ + { key: 'regionId', value: '{{regionId}}' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Session', 'GET', '/payments/sessions/:sessionId'), + createRequest('Create Session', 'POST', '/payments/sessions', [], { + cartId: '{{cartId}}', + providerId: '{{providerId}}', + returnUrl: 'http://localhost:3000/return', + }), + createRequest('Update Session', 'PATCH', '/payments/sessions/:sessionId', [], { + returnUrl: 'http://localhost:3000/return-updated', + }), + createRequest('Cancel Session', 'DELETE', '/payments/sessions/:sessionId'), +]; +frameworkModules.push(createFolder('Payments', paymentsRequests)); + +// Billing Accounts Module +const billingAccountsRequests = [ + createRequest('Get Billing Account List', 'GET', '/billing-accounts', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + ]), + createRequest('Get Billing Account', 'GET', '/billing-accounts/:billingAccountId'), +]; +frameworkModules.push(createFolder('Billing Accounts', billingAccountsRequests)); + +// Resources Module +const resourcesRequests = [ + createRequest('Get Service List', 'GET', '/resources/services', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Service', 'GET', '/resources/services/:serviceId', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Get Featured Services', 'GET', '/resources/services/featured'), + createRequest('Get Asset List', 'GET', '/resources/assets', [ + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + { key: 'locale', value: '{{locale}}' }, + ]), + createRequest('Get Asset', 'GET', '/resources/assets/:assetId', [{ key: 'locale', value: '{{locale}}' }]), + createRequest('Get Compatible Services', 'GET', '/resources/assets/:assetId/compatible-services'), + createRequest('Purchase Resource', 'POST', '/resources/:resourceId/purchase'), +]; +frameworkModules.push(createFolder('Resources', resourcesRequests)); + +// Search Module +const searchRequests = [createRequest('Search', 'GET', '/search', [{ key: 'index', value: 'articles' }])]; +frameworkModules.push(createFolder('Search', searchRequests)); + +// Page Module +const pageRequests = [ + createRequest('Get Init', 'GET', '/page/init', [{ key: 'referrer', value: '{{referrer}}' }]), + createRequest('Get Page', 'GET', '/page', [{ key: 'slug', value: '{{slug}}' }]), +]; + +// Blocks +const blocks = []; + +// Article Blocks +const articleBlocks = [ + createRequest('Get Article Block', 'GET', '/blocks/article', [{ key: 'slug', value: '{{articleId}}' }]), + createRequest('Get Article List Block', 'GET', '/blocks/article-list', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Article Search Block', 'GET', '/blocks/article-search', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Search Articles', 'GET', '/blocks/article-search/articles', [ + { key: 'query', value: 'search term' }, + { key: 'basePath', value: '/articles' }, + ]), +]; +blocks.push(createFolder('Article Blocks', articleBlocks)); + +// Category Blocks +const categoryBlocks = [ + createRequest('Get Category Block', 'GET', '/blocks/category', [{ key: 'id', value: '{{categoryId}}' }]), + createRequest('Get Category Articles', 'GET', '/blocks/category/articles', [ + { key: 'id', value: '{{categoryId}}' }, + { key: 'page', value: '1' }, + { key: 'limit', value: '10' }, + ]), + createRequest('Get Category List Block', 'GET', '/blocks/category-list', [{ key: 'id', value: '{{cmsEntryId}}' }]), +]; +blocks.push(createFolder('Category Blocks', categoryBlocks)); + +// Ticket Blocks +const ticketBlocks = [ + createRequest('Get Ticket List Block', 'GET', '/blocks/ticket-list', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Ticket Details Block', 'GET', '/blocks/ticket-details', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Get Ticket Recent Block', 'GET', '/blocks/ticket-recent', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Ticket Summary Block', 'GET', '/blocks/ticket-summary', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), +]; +blocks.push(createFolder('Ticket Blocks', ticketBlocks)); + +// Order Blocks +const orderBlocks = [ + createRequest('Get Order List Block', 'GET', '/blocks/order-list', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Order Details Block', 'GET', '/blocks/order-details', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Orders Summary Block', 'GET', '/blocks/orders-summary', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), +]; +blocks.push(createFolder('Order Blocks', orderBlocks)); + +// Invoice Blocks +const invoiceBlocks = [ + createRequest('Get Invoice List Block', 'GET', '/blocks/invoice-list', [{ key: 'id', value: '{{cmsEntryId}}' }]), +]; +blocks.push(createFolder('Invoice Blocks', invoiceBlocks)); + +// Notification Blocks +const notificationBlocks = [ + createRequest('Get Notification List Block', 'GET', '/blocks/notification-list', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Get Notification Details Block', 'GET', '/blocks/notification-details/:notificationId', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Mark Notification As', 'POST', '/blocks/notification-details', [], { + id: '{{notificationId}}', + status: 'read', + }), + createRequest('Get Notification Summary Block', 'GET', '/blocks/notification-summary', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), +]; +blocks.push(createFolder('Notification Blocks', notificationBlocks)); + +// Payment Blocks +const paymentBlocks = [ + createRequest('Get Payments Summary Block', 'GET', '/blocks/payments-summary', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Get Payments History Block', 'GET', '/blocks/payments-history', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), +]; +blocks.push(createFolder('Payment Blocks', paymentBlocks)); + +// Product Blocks +const productBlocks = [ + createRequest('Get Product List Block', 'GET', '/blocks/product-list', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Product Details Block', 'GET', '/blocks/product-details', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Get Recommended Products Block', 'GET', '/blocks/recommended-products', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), +]; +blocks.push(createFolder('Product Blocks', productBlocks)); + +// Service Blocks +const serviceBlocks = [ + createRequest('Get Service List Block', 'GET', '/blocks/service-list', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Service Details Block', 'GET', '/blocks/service-details', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Get Featured Service List Block', 'GET', '/blocks/featured-service-list', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), +]; +blocks.push(createFolder('Service Blocks', serviceBlocks)); + +// UI Component Blocks +const uiBlocks = [ + createRequest('Get Hero Section Block', 'GET', '/blocks/hero-section', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Feature Section Block', 'GET', '/blocks/feature-section', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Get Feature Section Grid Block', 'GET', '/blocks/feature-section-grid', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Get Media Section Block', 'GET', '/blocks/media-section', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Pricing Section Block', 'GET', '/blocks/pricing-section', [ + { key: 'id', value: '{{cmsEntryId}}' }, + ]), + createRequest('Get CTA Section Block', 'GET', '/blocks/cta-section', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Bento Grid Block', 'GET', '/blocks/bento-grid', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get FAQ Block', 'GET', '/blocks/faq', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Quick Links Block', 'GET', '/blocks/quick-links', [{ key: 'id', value: '{{cmsEntryId}}' }]), +]; +blocks.push(createFolder('UI Component Blocks', uiBlocks)); + +// Other Blocks +const otherBlocks = [ + createRequest('Get User Account Block', 'GET', '/blocks/user-account', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get Document List Block', 'GET', '/blocks/document-list', [{ key: 'id', value: '{{cmsEntryId}}' }]), + createRequest('Get SurveyJS Block', 'GET', '/blocks/surveyjs', [{ key: 'id', value: '{{cmsEntryId}}' }]), +]; +blocks.push(createFolder('Other Blocks', otherBlocks)); + +// Assemble collection +collection.item = [ + createFolder('Framework Modules', frameworkModules), + createFolder('Page', pageRequests), + createFolder('Blocks', blocks), +]; + +// Write to file +fs.writeFileSync('O2S-API.postman_collection.json', JSON.stringify(collection, null, 2)); +console.log('Postman collection generated successfully: O2S-API.postman_collection.json');