From 377267838ef84194dbd996ccf8d243cae57479be Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 9 Feb 2026 13:00:07 +0100 Subject: [PATCH 01/27] feat: add normalized data model for carts and Medusa.js integration - Introduced cart-related modules to Medusa.js integration with support for create, update, and delete operations. - Included mappings and services for handling carts across integrations. - Updated relevant configurations, docs, and seed data. --- apps/api-harmonization/src/app.config.ts | 2 + apps/api-harmonization/src/app.module.ts | 3 + .../configs/integrations/src/models/carts.ts | 9 + .../configs/integrations/src/models/index.ts | 1 + .../configs/integrations/src/models/orders.ts | 2 +- .../integrations/src/models/resources.ts | 2 +- packages/framework/src/api-config.ts | 7 + packages/framework/src/index.ts | 1 + .../src/modules/carts/carts.controller.ts | 82 +++ .../src/modules/carts/carts.model.ts | 74 +++ .../src/modules/carts/carts.module.ts | 29 + .../src/modules/carts/carts.request.ts | 86 +++ .../src/modules/carts/carts.service.ts | 50 ++ packages/framework/src/modules/carts/index.ts | 5 + .../integrations/medusajs/src/integration.ts | 6 + .../src/modules/carts/carts.mapper.ts | 144 +++++ .../src/modules/carts/carts.service.ts | 321 ++++++++++ .../medusajs/src/modules/carts/index.ts | 6 + .../medusajs/src/modules/index.ts | 1 + .../src/modules/medusajs/medusajs.service.ts | 58 +- packages/integrations/mocked/prisma/seed.ts | 2 +- .../integrations/mocked/src/integration.ts | 6 + .../mocked/src/modules/carts/carts.mapper.ts | 576 ++++++++++++++++++ .../mocked/src/modules/carts/carts.service.ts | 313 ++++++++++ .../mocked/src/modules/carts/index.ts | 6 + .../integrations/mocked/src/modules/index.ts | 1 + .../src/modules/orders/orders.mapper.ts | 2 +- .../organizations/organizations.mapper.ts | 2 +- .../src/modules/resources/resources.mapper.ts | 4 +- .../src/modules/users/customers.mapper.ts | 2 +- .../mocked/src/modules/users/users.mapper.ts | 2 +- 31 files changed, 1774 insertions(+), 31 deletions(-) create mode 100644 packages/configs/integrations/src/models/carts.ts create mode 100644 packages/framework/src/modules/carts/carts.controller.ts create mode 100644 packages/framework/src/modules/carts/carts.model.ts create mode 100644 packages/framework/src/modules/carts/carts.module.ts create mode 100644 packages/framework/src/modules/carts/carts.request.ts create mode 100644 packages/framework/src/modules/carts/carts.service.ts create mode 100644 packages/framework/src/modules/carts/index.ts create mode 100644 packages/integrations/medusajs/src/modules/carts/carts.mapper.ts create mode 100644 packages/integrations/medusajs/src/modules/carts/carts.service.ts create mode 100644 packages/integrations/medusajs/src/modules/carts/index.ts create mode 100644 packages/integrations/mocked/src/modules/carts/carts.mapper.ts create mode 100644 packages/integrations/mocked/src/modules/carts/carts.service.ts create mode 100644 packages/integrations/mocked/src/modules/carts/index.ts diff --git a/apps/api-harmonization/src/app.config.ts b/apps/api-harmonization/src/app.config.ts index f95396fea..7f46bf5de 100644 --- a/apps/api-harmonization/src/app.config.ts +++ b/apps/api-harmonization/src/app.config.ts @@ -4,6 +4,7 @@ import { BillingAccounts, CMS, Cache, + Carts, Invoices, Notifications, Orders, @@ -32,6 +33,7 @@ export const AppConfig: ApiConfig = { search: Search.SearchIntegrationConfig, products: Products.ProductsIntegrationConfig, orders: Orders.OrdersIntegrationConfig, + carts: Carts.CartsIntegrationConfig, auth: Auth.AuthIntegrationConfig, }, }; diff --git a/apps/api-harmonization/src/app.module.ts b/apps/api-harmonization/src/app.module.ts index d669d2f09..4a22a9d66 100644 --- a/apps/api-harmonization/src/app.module.ts +++ b/apps/api-harmonization/src/app.module.ts @@ -13,6 +13,7 @@ import { BillingAccounts, CMS, Cache, + Carts, Invoices, Notifications, Orders, @@ -79,6 +80,7 @@ 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 AuthModuleBaseModule = AuthModule.Module.register(AppConfig); @Module({ @@ -106,6 +108,7 @@ export const AuthModuleBaseModule = AuthModule.Module.register(AppConfig); SearchBaseModule, ProductsBaseModule, OrdersBaseModule, + CartsBaseModule, AuthModuleBaseModule, PageModule.register(AppConfig), diff --git a/packages/configs/integrations/src/models/carts.ts b/packages/configs/integrations/src/models/carts.ts new file mode 100644 index 000000000..7e461bfcb --- /dev/null +++ b/packages/configs/integrations/src/models/carts.ts @@ -0,0 +1,9 @@ +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; diff --git a/packages/configs/integrations/src/models/index.ts b/packages/configs/integrations/src/models/index.ts index c23e92ba1..94388ff09 100644 --- a/packages/configs/integrations/src/models/index.ts +++ b/packages/configs/integrations/src/models/index.ts @@ -2,6 +2,7 @@ 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 CMS from './cms'; export * as Invoices from './invoices'; export * as Notifications from './notifications'; diff --git a/packages/configs/integrations/src/models/orders.ts b/packages/configs/integrations/src/models/orders.ts index 413ebd794..137dfb770 100644 --- a/packages/configs/integrations/src/models/orders.ts +++ b/packages/configs/integrations/src/models/orders.ts @@ -1,4 +1,4 @@ -import { Config, Integration } from '@o2s/integrations.mocked/integration'; +import { Config, Integration } from '@o2s/integrations.medusajs/integration'; import { ApiConfig } from '@o2s/framework/modules'; diff --git a/packages/configs/integrations/src/models/resources.ts b/packages/configs/integrations/src/models/resources.ts index b0ac78b10..429c258aa 100644 --- a/packages/configs/integrations/src/models/resources.ts +++ b/packages/configs/integrations/src/models/resources.ts @@ -1,4 +1,4 @@ -import { Config, Integration } from '@o2s/integrations.mocked/integration'; +import { Config, Integration } from '@o2s/integrations.medusajs/integration'; import { ApiConfig } from '@o2s/framework/modules'; diff --git a/packages/framework/src/api-config.ts b/packages/framework/src/api-config.ts index 17ea6e951..8b2fd9cdb 100644 --- a/packages/framework/src/api-config.ts +++ b/packages/framework/src/api-config.ts @@ -6,6 +6,7 @@ import { BillingAccounts, CMS, Cache, + Carts, Invoices, Notifications, Orders, @@ -98,6 +99,12 @@ export interface ApiConfig { controller?: typeof Orders.Controller; imports?: Type[]; }; + carts: { + name: string; + service: typeof Carts.Service; + controller?: typeof Carts.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..79223f5fd 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -16,3 +16,4 @@ 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'; 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..7116b98b7 --- /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/:promotionId') + 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..91aeb493d --- /dev/null +++ b/packages/framework/src/modules/carts/carts.model.ts @@ -0,0 +1,74 @@ +import { ShippingMethod } from '../orders/orders.model'; +import { Product } from '../products/products.model'; + +import { Address, Pagination, Price, Unit } from '@/utils/models'; + +export type CartType = 'ACTIVE' | 'SAVED' | 'ABANDONED'; + +export type PaymentMethodType = 'CREDIT_CARD' | 'PAYPAL' | 'BANK_TRANSFER' | 'OTHER'; + +export type PromotionType = 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_SHIPPING'; + +export type PromotionScope = 'CART' | 'ITEM' | 'SHIPPING'; + +export class PaymentMethod { + id!: string; + name!: string; + description?: string; + type!: PaymentMethodType; +} + +export class Promotion { + id!: string; + code!: string; + name!: string; + description?: string; + type!: PromotionType; + value!: number; + appliedTo!: PromotionScope; +} + +export class CartItem { + id!: string; + productId!: string; + variantId?: 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; + type!: CartType; + 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; +} + +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..ca6580928 --- /dev/null +++ b/packages/framework/src/modules/carts/carts.request.ts @@ -0,0 +1,86 @@ +import { CartType, PaymentMethodType } from './carts.model'; +import { Price } from '@/utils/models'; +import { PaginationQuery } from '@/utils/models/pagination'; + +// Cart retrieval +export class GetCartParams { + id!: string; +} + +export class GetCartListQuery extends PaginationQuery { + customerId?: string; + type?: CartType; + sort?: string; + locale?: string; +} + +// Cart creation and updates +export class CreateCartBody { + customerId?: string; + name?: string; + type?: CartType; + regionId?: string; + currency!: Price.Currency; + metadata?: Record; +} + +export class UpdateCartParams { + id!: string; +} + +export class UpdateCartBody { + name?: string; + type?: CartType; + regionId?: string; + shippingAddressId?: string; + billingAddressId?: string; + shippingMethodId?: string; + paymentMethodId?: string; + paymentMethodType?: PaymentMethodType; + notes?: string; + metadata?: Record; +} + +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 + productId!: string; + variantId?: string; + quantity!: number; + currency?: Price.Currency; // Required if creating new cart + regionId?: string; // Required if creating new cart (for Medusa.js) + metadata?: Record; +} + +export class UpdateCartItemParams { + cartId!: string; + itemId!: string; +} + +export class UpdateCartItemBody { + quantity?: number; + metadata?: Record; +} + +export class RemoveCartItemParams { + cartId!: string; + itemId!: string; +} + +// Promotion operations +export class ApplyPromotionParams { + cartId!: string; +} + +export class ApplyPromotionBody { + code!: string; +} + +export class RemovePromotionParams { + cartId!: string; + promotionId!: 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..c2de5376e --- /dev/null +++ b/packages/framework/src/modules/carts/carts.service.ts @@ -0,0 +1,50 @@ +import { Observable } from 'rxjs'; + +import * as Carts from './'; + +export abstract class CartService { + protected constructor(..._services: unknown[]) {} + + abstract getCart( + params: Carts.Request.GetCartParams, + authorization?: string, + ): Observable; + + abstract getCartList(query: Carts.Request.GetCartListQuery, authorization?: string): Observable; + + abstract createCart(data: Carts.Request.CreateCartBody, authorization?: string): Observable; + + abstract updateCart( + params: Carts.Request.UpdateCartParams, + data: Carts.Request.UpdateCartBody, + authorization?: string, + ): Observable; + + abstract deleteCart(params: Carts.Request.DeleteCartParams, authorization?: string): Observable; + + abstract addCartItem(data: Carts.Request.AddCartItemBody, authorization?: string): Observable; + + abstract updateCartItem( + params: Carts.Request.UpdateCartItemParams, + data: Carts.Request.UpdateCartItemBody, + authorization?: string, + ): Observable; + + abstract removeCartItem( + params: Carts.Request.RemoveCartItemParams, + authorization?: string, + ): Observable; + + abstract applyPromotion( + params: Carts.Request.ApplyPromotionParams, + data: Carts.Request.ApplyPromotionBody, + authorization?: string, + ): Observable; + + abstract removePromotion( + params: Carts.Request.RemovePromotionParams, + authorization?: string, + ): Observable; + + abstract getCurrentCart(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/integrations/medusajs/src/integration.ts b/packages/integrations/medusajs/src/integration.ts index f9266939e..f9e251baf 100644 --- a/packages/integrations/medusajs/src/integration.ts +++ b/packages/integrations/medusajs/src/integration.ts @@ -3,6 +3,7 @@ import { Auth } from '@o2s/framework/modules'; import { MedusaJsModule } from '@/modules/medusajs/medusajs.module'; +import { Service as CartsService } from './modules/carts'; import { Service as OrdersService } from './modules/orders'; import { Service as ProductsService } from './modules/products'; import { Service as ResourcesService } from './modules/resources'; @@ -25,4 +26,9 @@ export const Config: Partial = { service: ProductsService, imports: [MedusaJsModule, Auth.Module], }, + carts: { + name: 'medusajs', + service: CartsService, + imports: [MedusaJsModule, Auth.Module], + }, }; 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..a3da3fb53 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -0,0 +1,144 @@ +import { HttpTypes } from '@medusajs/types'; + +import { Carts, Models, Orders, Products } from '@o2s/framework/modules'; + +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 => { + const currency = (cart.currency_code as Models.Price.Currency) ?? (defaultCurrency as Models.Price.Currency); + + return { + id: cart.id, + customerId: cart.customer_id ?? undefined, + name: undefined, // Medusa doesn't have cart names by default + type: 'ACTIVE', // Medusa carts are active 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), + discountTotal: mapPrice(cart.discount_total, currency), + taxTotal: mapPrice(cart.tax_total, currency), + shippingTotal: mapPrice(cart.shipping_total, currency), + total: mapPrice(cart.total, currency) as Models.Price.Price, + shippingAddress: mapAddress(cart.shipping_address), + billingAddress: mapAddress(cart.billing_address), + shippingMethod: cart.shipping_methods?.[0] ? mapShippingMethod(cart.shipping_methods[0], currency) : undefined, + paymentMethod: undefined, // Map from payment collection if available + promotions: mapPromotions(cart), + metadata: (cart.metadata as Record) ?? {}, + notes: undefined, + }; +}; + +const mapCartItem = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.Currency): Carts.Model.CartItem => { + return { + id: item.id, + productId: item.product_id ?? '', + variantId: item.variant_id ?? undefined, + quantity: item.quantity, + price: mapPrice(item.unit_price, currency) as Models.Price.Price, + subtotal: mapPrice(item.subtotal, currency), + discountTotal: mapPrice(item.discount_total, currency), + total: mapPrice(item.total, currency) as Models.Price.Price, + unit: 'PCS', + currency, + product: mapProduct(item, currency), + metadata: (item.metadata as Record) ?? {}, + }; +}; + +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) as Models.Price.Price, + link: '', + type: 'PHYSICAL' as Products.Model.ProductType, + category: '', + tags: [], + }; +}; + +const mapAddress = (address?: HttpTypes.StoreCartAddress | 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.StoreCartShippingMethod, + 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 mapPromotions = (cart: HttpTypes.StoreCart): Carts.Model.Promotion[] | undefined => { + // Medusa v2 uses promotions differently - map from adjustments if available + const promotions: Carts.Model.Promotion[] = []; + + // Extract promotion codes from cart if available + if (cart.promotions && Array.isArray(cart.promotions)) { + for (const promo of cart.promotions) { + promotions.push({ + id: promo.id ?? '', + code: promo.code ?? '', + name: promo.code ?? '', + description: undefined, + type: 'PERCENTAGE', // Default type + value: 0, + appliedTo: 'CART', + }); + } + } + + return promotions.length > 0 ? promotions : undefined; +}; + +const mapPrice = ( + value: number | undefined | null, + currency: Models.Price.Currency, +): Models.Price.Price | undefined => { + if (typeof value === 'undefined' || value === null) return undefined; + return { + value, + currency, + }; +}; 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..c10d0c149 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -0,0 +1,321 @@ +import Medusa from '@medusajs/js-sdk'; +import { HttpService } from '@nestjs/axios'; +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, + NotImplementedException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Observable, catchError, from, map, of, switchMap, throwError } from 'rxjs'; + +import { LoggerService } from '@o2s/utils.logger'; + +import { Auth, Carts } from '@o2s/framework/modules'; + +import { Service as MedusaJsService } from '@/modules/medusajs'; + +import { handleHttpError } from '../utils/handle-http-error'; + +import { mapCart } from './carts.mapper'; + +@Injectable() +export class CartsService extends Carts.Service { + private readonly sdk: Medusa; + private readonly defaultCurrency: string; + + constructor( + private readonly config: ConfigService, + protected httpClient: HttpService, + @Inject(LoggerService) protected readonly logger: LoggerService, + private readonly medusaJsService: MedusaJsService, + private readonly authService: Auth.Service, + ) { + super(); + this.sdk = this.medusaJsService.getSdk(); + this.defaultCurrency = this.config.get('DEFAULT_CURRENCY') || ''; + + if (!this.defaultCurrency) { + throw new Error('DEFAULT_CURRENCY is not defined'); + } + } + + getCart(params: Carts.Request.GetCartParams, authorization?: string): Observable { + return from(this.sdk.store.cart.retrieve(params.id, {}, this.getHeaders())).pipe( + map((response) => { + 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) { + return of(undefined); + } + 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, + }, + {}, + this.getHeaders(), + ), + ).pipe( + map((response) => mapCart(response.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + updateCart( + params: Carts.Request.UpdateCartParams, + data: Carts.Request.UpdateCartBody, + _authorization?: string, + ): Observable { + return from( + this.sdk.store.cart.update( + params.id, + { + region_id: data.regionId, + metadata: data.metadata, + }, + {}, + this.getHeaders(), + ), + ).pipe( + map((response) => mapCart(response.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + deleteCart(_params: Carts.Request.DeleteCartParams, _authorization?: string): Observable { + this.logger.debug('Delete cart operation - not directly supported in Medusa Store API'); + + return of(undefined as void); + } + + addCartItem(data: Carts.Request.AddCartItemBody, authorization?: string): Observable { + if (!data.variantId) { + throw new BadRequestException('Variant ID 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; // Store for type narrowing + return from(this.sdk.store.cart.retrieve(cartId, {}, this.getHeaders())).pipe( + switchMap((response) => { + const cart = mapCart(response.cart, this.defaultCurrency); + + // Verify ownership for customer carts + if (cart.customerId && authorization && cart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to modify this cart'); + } + + // Add item to existing cart + return from( + this.sdk.store.cart.createLineItem( + cartId, + { + variant_id: data.variantId!, + quantity: data.quantity, + metadata: data.metadata, + }, + {}, + this.getHeaders(), + ), + ); + }), + map((addResponse) => mapCart(addResponse.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + // No cartId provided - create a new cart + if (!data.currency) { + throw new BadRequestException('Currency is required when creating a new cart'); + } + + // For authenticated users, create a new cart (can't query existing carts) + // Medusa doesn't provide a way to query carts by customer + // Store API doesn't support listing carts + // Admin API doesn't have /admin/carts endpoint + if (customerId) { + return this.createCartAndAddItem( + data.currency!, + data.variantId!, + data.quantity, + data.regionId, + data.metadata, + ); + } + + // For guests, create a new cart + return this.createCartAndAddItem(data.currency, data.variantId!, data.quantity, data.regionId, data.metadata); + } + + updateCartItem( + params: Carts.Request.UpdateCartItemParams, + data: Carts.Request.UpdateCartItemBody, + _authorization: string | undefined, + ): Observable { + return from( + this.sdk.store.cart.updateLineItem( + params.cartId, + params.itemId, + { + quantity: data.quantity, + metadata: data.metadata, + }, + {}, + this.getHeaders(), + ), + ).pipe( + map((response) => 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, this.getHeaders())).pipe( + map((response) => { + 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 | undefined, + ): Observable { + return from( + this.sdk.store.cart.update( + params.cartId, + { + promo_codes: [data.code], + }, + {}, + this.getHeaders(), + ), + ).pipe( + map((response) => mapCart(response.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + removePromotion( + params: Carts.Request.RemovePromotionParams, + _authorization?: string, + ): Observable { + // In Medusa v2, removing promotions requires updating the cart + // with the remaining promo codes (excluding the one to remove) + return from(this.sdk.store.cart.retrieve(params.cartId, {}, this.getHeaders())).pipe( + switchMap((response) => { + const cart = response.cart; + // Filter out the promotion to remove + const remainingCodes = + cart.promotions + ?.filter((promo: { id?: string }) => promo.id !== params.promotionId) + .map((promo: { code?: string }) => promo.code) + .filter((code: string | undefined): code is string => code !== undefined) ?? []; + + return from( + this.sdk.store.cart.update( + params.cartId, + { + promo_codes: remainingCodes, + }, + {}, + this.getHeaders(), + ), + ); + }), + map((response) => mapCart(response.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + getCurrentCart(_authorization: string | undefined): 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.', + ), + ); + } + + private createCartAndAddItem( + currency: string, + variantId: string, + quantity: number, + regionId?: string, + metadata?: Record, + ): Observable { + return from( + this.sdk.store.cart.create( + { + currency_code: currency.toLowerCase(), + region_id: regionId, + metadata, + }, + {}, + this.getHeaders(), + ), + ).pipe( + switchMap((createResponse) => + from( + this.sdk.store.cart.createLineItem( + createResponse.cart.id, + { + variant_id: variantId, + quantity, + metadata, + }, + {}, + this.getHeaders(), + ), + ), + ), + map((addResponse) => mapCart(addResponse.cart, this.defaultCurrency)), + catchError((error) => handleHttpError(error)), + ); + } + + private getHeaders(): Record { + return { + 'x-publishable-api-key': this.medusaJsService.getPublishableKey(), + }; + } +} 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/index.ts b/packages/integrations/medusajs/src/modules/index.ts index be7a1e06c..0e787698c 100644 --- a/packages/integrations/medusajs/src/modules/index.ts +++ b/packages/integrations/medusajs/src/modules/index.ts @@ -1,4 +1,5 @@ export * as Orders from './orders'; export * as Products from './products'; export * as Resources from './resources'; +export * as Carts from './carts'; export * as MedusaJs from './medusajs'; diff --git a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts index cc40fc0a2..254145861 100644 --- a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts +++ b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts @@ -6,54 +6,68 @@ import { ConfigService } from '@nestjs/config'; @Injectable() export class MedusaJsService { private readonly logLevel: string; - private readonly medusaBaseUrl: string; - private readonly medusaPublishableApiKey: string; - private readonly medusaAdminApiKey: string; - private readonly medusaAdminApiKeyEncoded: string; - private readonly sdk: Medusa; + private _medusaBaseUrl: string | null = null; + private _medusaPublishableApiKey: string | null = null; + private _medusaAdminApiKey: string | null = null; + private _medusaAdminApiKeyEncoded: string | null = null; + private _sdk: Medusa | null = null; constructor(private readonly config: ConfigService) { - this.medusaBaseUrl = this.config.get('MEDUSAJS_BASE_URL') || ''; - this.medusaPublishableApiKey = this.config.get('MEDUSAJS_PUBLISHABLE_API_KEY') || ''; - this.medusaAdminApiKey = this.config.get('MEDUSAJS_ADMIN_API_KEY') || ''; this.logLevel = this.config.get('LOG_LEVEL') || ''; - if (!this.medusaBaseUrl) { + } + + private ensureInitialized(): void { + if (this._sdk !== null) { + return; + } + + this._medusaBaseUrl = this.config.get('MEDUSAJS_BASE_URL') || ''; + this._medusaPublishableApiKey = this.config.get('MEDUSAJS_PUBLISHABLE_API_KEY') || ''; + this._medusaAdminApiKey = this.config.get('MEDUSAJS_ADMIN_API_KEY') || ''; + + if (!this._medusaBaseUrl) { throw new Error('MEDUSAJS_BASE_URL is not defined'); } - if (!this.medusaPublishableApiKey) { + if (!this._medusaPublishableApiKey) { throw new Error('MEDUSAJS_PUBLISHABLE_API_KEY is not defined'); } - if (!this.medusaAdminApiKey) { + if (!this._medusaAdminApiKey) { throw new Error('MEDUSAJS_ADMIN_API_KEY is not defined'); } - this.sdk = new Medusa({ - baseUrl: this.medusaBaseUrl, - debug: this.logLevel === 'debug', - publishableKey: this.medusaPublishableApiKey, - apiKey: this.medusaAdminApiKey, + this._sdk = new Medusa({ + baseUrl: this._medusaBaseUrl, + // debug: this.logLevel === 'debug', + debug: true, + publishableKey: this._medusaPublishableApiKey, + apiKey: this._medusaAdminApiKey, }); - this.medusaAdminApiKeyEncoded = Buffer.from(this.medusaAdminApiKey).toString('base64'); + this._medusaAdminApiKeyEncoded = Buffer.from(this._medusaAdminApiKey).toString('base64'); } getSdk(): Medusa { - return this.sdk; + this.ensureInitialized(); + return this._sdk!; } getBaseUrl(): string { - return this.medusaBaseUrl; + this.ensureInitialized(); + return this._medusaBaseUrl!; } getPublishableKey(): string { - return this.medusaPublishableApiKey; + this.ensureInitialized(); + return this._medusaPublishableApiKey!; } getAdminKey(): string { - return this.medusaAdminApiKey; + this.ensureInitialized(); + return this._medusaAdminApiKey!; } getAdminKeyEncoded(): string { - return this.medusaAdminApiKeyEncoded; + this.ensureInitialized(); + return this._medusaAdminApiKeyEncoded!; } getMedusaAdminApiHeaders() { diff --git a/packages/integrations/mocked/prisma/seed.ts b/packages/integrations/mocked/prisma/seed.ts index 66dd7980c..027fb8367 100644 --- a/packages/integrations/mocked/prisma/seed.ts +++ b/packages/integrations/mocked/prisma/seed.ts @@ -15,7 +15,7 @@ async function main() { name: 'Jane Doe', email: 'jane@example.com', password: await hash('admin', 10), - defaultCustomerId: 'cust-001', // Acme Corp - full admin permissions + defaultCustomerId: 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', // Acme Corp - full admin permissions }, { id: 'user-100', diff --git a/packages/integrations/mocked/src/integration.ts b/packages/integrations/mocked/src/integration.ts index a46a84c8c..711e1a539 100644 --- a/packages/integrations/mocked/src/integration.ts +++ b/packages/integrations/mocked/src/integration.ts @@ -4,6 +4,7 @@ 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 CmsService } from './modules/cms'; import { Service as InvoicesService } from './modules/invoices'; import { Service as NotificationsService } from './modules/notifications'; @@ -73,6 +74,11 @@ export const Config: Partial = { name: 'mocked', service: ProductsService, }, + carts: { + name: 'mocked', + service: CartsService, + imports: [Auth.Module], + }, auth: { name: 'mocked', service: AuthService, 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..33b7ec3c0 --- /dev/null +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -0,0 +1,576 @@ +import { Carts, Products } from '@o2s/framework/modules'; + +// Product data for generating cart items +const PRODUCT_DATA = [ + { + id: 'PRD-004', + name: 'Rotary Hammer', + price: 100, + currency: 'USD', + type: 'PHYSICAL', + category: 'TOOLS', + sku: 'RH-12345-S-BL', + }, + { + id: 'PRD-005', + name: 'Angle Grinder', + price: 79.99, + currency: 'USD', + type: 'PHYSICAL', + category: 'TOOLS', + sku: 'AG-12345-S-BL', + }, + { + id: 'PRD-006', + name: 'Cordless Drill', + price: 129.99, + currency: 'USD', + type: 'PHYSICAL', + category: 'TOOLS', + sku: 'CD-12345-S-BL', + }, + { + id: 'PRD-007', + name: 'Laser Measure', + price: 149.99, + currency: 'USD', + type: 'PHYSICAL', + category: 'TOOLS', + sku: 'LM-12345-S-BL', + }, + { + id: 'PRD-008', + name: 'Safety Glasses', + price: 19.99, + currency: 'USD', + type: 'PHYSICAL', + category: 'SAFETY', + sku: 'SG-12345-S-BL', + }, +]; + +// Shipping methods +const SHIPPING_METHODS = [ + { + id: 'SHIP-001', + name: 'Standard Shipping', + description: '3-5 business days', + price: 10, + }, + { + id: 'SHIP-002', + name: 'Express Shipping', + description: '1-2 business days', + price: 20, + }, +]; + +// Payment methods +const PAYMENT_METHODS: Carts.Model.PaymentMethod[] = [ + { + id: 'PAY-001', + name: 'Credit Card', + description: 'Visa, Mastercard, American Express', + type: 'CREDIT_CARD', + }, + { + id: 'PAY-002', + name: 'PayPal', + description: 'Pay with your PayPal account', + type: 'PAYPAL', + }, + { + id: 'PAY-003', + name: 'Bank Transfer', + description: 'Direct bank transfer', + type: 'BANK_TRANSFER', + }, +]; + +// Promotions +const PROMOTIONS: Carts.Model.Promotion[] = [ + { + id: 'PROMO-001', + code: 'SAVE10', + name: '10% Off', + description: 'Get 10% off your order', + type: 'PERCENTAGE', + value: 10, + appliedTo: 'CART', + }, + { + id: 'PROMO-002', + code: 'FREESHIP', + name: 'Free Shipping', + description: 'Free standard shipping', + type: 'FREE_SHIPPING', + value: 0, + appliedTo: 'SHIPPING', + }, +]; + +// Helper functions +const getRandomInt = (min: number, max: number): number => { + return Math.floor(Math.random() * (max - min + 1)) + min; +}; + +const formatDate = (date: Date): string => { + return date.toISOString(); +}; + +// Function to generate a cart item +const generateCartItem = (itemIndex: number): Carts.Model.CartItem => { + const productIndex = getRandomInt(0, PRODUCT_DATA.length - 1); + const product = PRODUCT_DATA[productIndex]!; + const quantity = getRandomInt(1, 3); + const price = product.price; + const subtotal = price * quantity; + const discountTotal = 0; + const total = subtotal - discountTotal; + + return { + id: `ITEM-${itemIndex.toString().padStart(3, '0')}`, + productId: product.id, + variantId: undefined, + quantity, + price: { + value: price, + currency: product.currency as Carts.Model.Cart['currency'], + }, + subtotal: { + value: subtotal, + currency: product.currency as Carts.Model.Cart['currency'], + }, + discountTotal: { + value: discountTotal, + currency: product.currency as Carts.Model.Cart['currency'], + }, + total: { + value: total, + currency: product.currency as Carts.Model.Cart['currency'], + }, + unit: 'PCS', + currency: product.currency as Carts.Model.Cart['currency'], + product: { + id: product.id, + sku: product.sku, + name: product.name, + description: `Description for ${product.name}`, + shortDescription: `Short description for ${product.name}`, + image: { + url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/empty.jpg', + width: 640, + height: 656, + alt: product.name, + }, + price: { + value: product.price, + currency: product.currency as Carts.Model.Cart['currency'], + }, + link: `https://example.com/products/${product.id.toLowerCase()}`, + type: product.type as Products.Model.Product['type'], + category: product.category as Products.Model.Product['category'], + tags: [ + { + label: 'New', + variant: 'secondary', + }, + ], + }, + metadata: {}, + }; +}; + +// Function to generate a cart +const generateCart = ( + cartId: string, + customerId: string | undefined, + type: Carts.Model.CartType, + name?: string, +): Carts.Model.Cart => { + const now = new Date(); + const createdAt = new Date(now.getTime() - getRandomInt(1, 7) * 24 * 60 * 60 * 1000); + const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days from now + + const numItems = getRandomInt(1, 4); + const items: Carts.Model.CartItem[] = []; + + let subtotal = 0; + for (let i = 0; i < numItems; i++) { + const item = generateCartItem(i); + items.push(item); + subtotal += item.total?.value || 0; + } + + const currency = items[0]?.currency || 'USD'; + const shippingMethod = SHIPPING_METHODS[0]!; + const shippingTotal = shippingMethod.price; + const discountTotal = type === 'ACTIVE' ? Math.round(subtotal * 0.1 * 100) / 100 : 0; + const taxTotal = Math.round((subtotal - discountTotal) * 0.23 * 100) / 100; // 23% tax + const total = subtotal + shippingTotal - discountTotal + taxTotal; + + return { + id: cartId, + customerId, + name: name || (type === 'SAVED' ? 'Saved Cart' : undefined), + type, + createdAt: formatDate(createdAt), + updatedAt: formatDate(now), + expiresAt: formatDate(expiresAt), + regionId: 'reg_01JS1JBXAPK2BTV504ASWVFC4S', // Mock region ID + currency, + items: { + data: items, + total: items.length, + }, + subtotal: { + value: subtotal, + currency, + }, + discountTotal: { + value: discountTotal, + currency, + }, + taxTotal: { + value: taxTotal, + currency, + }, + shippingTotal: { + value: shippingTotal, + currency, + }, + total: { + value: total, + currency, + }, + shippingAddress: { + country: 'US', + streetName: 'Main St', + streetNumber: '123', + city: 'Anytown', + region: 'CA', + postalCode: '12345', + phone: '555-123-4567', + email: 'john.doe@example.com', + }, + billingAddress: { + country: 'US', + streetName: 'Main St', + streetNumber: '123', + city: 'Anytown', + region: 'CA', + postalCode: '12345', + phone: '555-123-4567', + email: 'john.doe@example.com', + }, + shippingMethod: { + id: shippingMethod.id, + name: shippingMethod.name, + description: shippingMethod.description, + total: { + value: shippingMethod.price, + currency, + }, + }, + paymentMethod: PAYMENT_METHODS[0], + promotions: type === 'ACTIVE' ? [PROMOTIONS[0]!] : [], + metadata: {}, + notes: undefined, + }; +}; + +// Generate mocked carts +const MOCKED_CARTS: Carts.Model.Cart[] = [ + // Active cart for authenticated customer + generateCart('CART-001', 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', 'ACTIVE'), + // Saved carts for authenticated customer + generateCart('CART-002', 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', 'SAVED', 'Wishlist'), + generateCart('CART-003', 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', 'SAVED', 'Work Equipment'), + // Abandoned cart + generateCart('CART-004', 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', 'ABANDONED'), + // Guest cart (no customer ID) + generateCart('CART-005', undefined, 'ACTIVE'), +]; + +// In-memory store for carts (for mutations) +let cartsStore = [...MOCKED_CARTS]; + +// Reset store (useful for testing) +export const resetCartsStore = (): void => { + cartsStore = [...MOCKED_CARTS]; +}; + +// 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, type, sort } = query; + + let filteredCarts = cartsStore.filter((cart) => { + if (customerId && cart.customerId !== customerId) return false; + if (type && cart.type !== type) 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-${(cartsStore.length + 1).toString().padStart(3, '0')}`; + 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, + type: data.type || 'ACTIVE', + 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; +}; + +// Update a cart +export const updateCart = ( + params: Carts.Request.UpdateCartParams, + data: Carts.Request.UpdateCartBody, +): Carts.Model.Cart | undefined => { + const cartIndex = cartsStore.findIndex((cart) => cart.id === params.id); + if (cartIndex === -1) return undefined; + + const cart = cartsStore[cartIndex]!; + const updatedCart: Carts.Model.Cart = { + ...cart, + name: data.name ?? cart.name, + type: data.type ?? cart.type, + regionId: data.regionId ?? cart.regionId, + notes: data.notes ?? cart.notes, + metadata: data.metadata ?? cart.metadata, + 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 && cart.type === 'ACTIVE'); +}; + +export const addCartItem = (cartId: string, data: Carts.Request.AddCartItemBody): Carts.Model.Cart | undefined => { + const cartIndex = cartsStore.findIndex((cart) => cart.id === cartId); + if (cartIndex === -1) return undefined; + + const cart = cartsStore[cartIndex]!; + const product = PRODUCT_DATA.find((p) => p.id === data.productId); + + if (!product) return undefined; + + const newItem: Carts.Model.CartItem = { + id: `ITEM-${cart.items.data.length.toString().padStart(3, '0')}`, + productId: data.productId, + variantId: data.variantId, + quantity: data.quantity, + price: { value: product.price, currency: cart.currency }, + subtotal: { value: product.price * data.quantity, currency: cart.currency }, + discountTotal: { value: 0, currency: cart.currency }, + total: { value: product.price * data.quantity, currency: cart.currency }, + unit: 'PCS', + currency: cart.currency, + product: { + id: product.id, + sku: product.sku, + name: product.name, + description: `Description for ${product.name}`, + price: { value: product.price, currency: cart.currency }, + link: `https://example.com/products/${product.id.toLowerCase()}`, + type: product.type as Products.Model.Product['type'], + category: product.category, + tags: [], + }, + metadata: 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) { + 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.id === params.promotionId); + 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' && promo.appliedTo === 'CART') { + discountTotal += (subtotal * promo.value) / 100; + } else if (promo.type === 'FIXED_AMOUNT' && promo.appliedTo === 'CART') { + discountTotal += promo.value; + } + } + } + + 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..2d0d43dbd --- /dev/null +++ b/packages/integrations/mocked/src/modules/carts/carts.service.ts @@ -0,0 +1,313 @@ +import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { Observable, of } from 'rxjs'; + +import { Auth, Carts } from '@o2s/framework/modules'; + +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) {} + + getCart( + params: Carts.Request.GetCartParams, + authorization: string | undefined, + ): Observable { + const cart = mapCart(params); + + // For guest carts (no customerId), allow access without auth + if (cart && !cart.customerId) { + return of(cart).pipe(responseDelay()); + } + + // For customer carts, verify authorization + if (authorization) { + const customerId = this.authService.getCustomerId(authorization); + if (cart && cart.customerId && 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 { + let customerId: string | undefined; + + if (authorization) { + 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'); + } + + // Verify authorization for customer carts + if (existingCart.customerId && authorization) { + 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'); + } + + // Verify authorization for customer carts + if (existingCart.customerId && authorization) { + 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'); + } + + // Verify authorization for customer carts + if (existingCart.customerId && authorization) { + 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 NotFoundException('Currency is required when creating a new cart'); + } + cart = createCart({ + customerId, + type: 'ACTIVE', + currency: data.currency, + regionId: data.regionId, + }); + } + } else { + // For guests: create new cart + if (!data.currency) { + throw new NotFoundException('Currency is required when creating a new cart'); + } + cart = createCart({ + currency: data.currency, + regionId: data.regionId, + type: 'ACTIVE', + }); + } + + cartId = cart.id; + } + + // Add item to cart + const updatedCart = addCartItem(cartId, data); + 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'); + } + + // Verify authorization for customer carts + if (existingCart.customerId && authorization) { + 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'); + } + + // Verify authorization for customer carts + if (existingCart.customerId && authorization) { + 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'); + } + + // Verify authorization for customer carts + if (existingCart.customerId && authorization) { + 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'); + } + + // Verify authorization for customer carts + if (existingCart.customerId && authorization) { + 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()); + } +} 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/index.ts b/packages/integrations/mocked/src/modules/index.ts index 0e19618ee..36c66eb55 100644 --- a/packages/integrations/mocked/src/modules/index.ts +++ b/packages/integrations/mocked/src/modules/index.ts @@ -11,4 +11,5 @@ 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 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..46111e62b 100644 --- a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts @@ -140,7 +140,7 @@ const DOCUMENT_DATA: Orders.Model.Document[] = [ ]; // Customer IDs -const CUSTOMER_IDS = ['cust-001']; +const CUSTOMER_IDS = ['cus_01KGSR4NSX1S7Y48E6MVWPPVDP']; // Shipping methods const SHIPPING_METHODS = [ diff --git a/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts b/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts index e47da557d..d6a8c86f2 100644 --- a/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts +++ b/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts @@ -66,7 +66,7 @@ const MOCK_ORGANIZATION_1: Organizations.Model.Organization = { }, isActive: true, children: [MOCK_ORGANIZATION_2, MOCK_ORGANIZATION_3], - customers: [mapCustomer('cust-001')!], + customers: [mapCustomer('cus_01KGSR4NSX1S7Y48E6MVWPPVDP')!], }; const MOCK_ORGANIZATIONS = [MOCK_ORGANIZATION_1, MOCK_ORGANIZATION_2, MOCK_ORGANIZATION_3]; diff --git a/packages/integrations/mocked/src/modules/resources/resources.mapper.ts b/packages/integrations/mocked/src/modules/resources/resources.mapper.ts index 869e2182a..ab4f70d42 100644 --- a/packages/integrations/mocked/src/modules/resources/resources.mapper.ts +++ b/packages/integrations/mocked/src/modules/resources/resources.mapper.ts @@ -395,7 +395,7 @@ export const mapAssets = ( let assets: Resources.Model.Asset[] = []; switch (customerId) { - case 'cust-001': + case 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP': assets = MOCK_ASSETS_FOR_CUSTOMER_1; break; case 'cust-002': @@ -474,7 +474,7 @@ export const mapServices = ( let services = MOCK_SERVICES_DEFAULT; switch (customerId) { - case 'cust-001': + case 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP': services = MOCK_SERVICES_FOR_CUSTOMER_1; break; case 'cust-002': diff --git a/packages/integrations/mocked/src/modules/users/customers.mapper.ts b/packages/integrations/mocked/src/modules/users/customers.mapper.ts index e264109a5..611b27d30 100644 --- a/packages/integrations/mocked/src/modules/users/customers.mapper.ts +++ b/packages/integrations/mocked/src/modules/users/customers.mapper.ts @@ -32,7 +32,7 @@ const PROSPECT_PERMISSIONS: Auth.Model.Permissions = { }; const MOCK_CUSTOMER_1: Models.Customer.Customer = { - id: 'cust-001', + id: 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', name: 'Acme Corporation', clientType: 'B2B', address: { diff --git a/packages/integrations/mocked/src/modules/users/users.mapper.ts b/packages/integrations/mocked/src/modules/users/users.mapper.ts index 6e614bc83..8da1e853d 100644 --- a/packages/integrations/mocked/src/modules/users/users.mapper.ts +++ b/packages/integrations/mocked/src/modules/users/users.mapper.ts @@ -23,7 +23,7 @@ const MOCK_USER_2: Users.Model.User = { firstName: 'Jane', lastName: 'Doe', customers: [ - mapCustomer('cust-001')!, // Acme Corp - admin permissions (full access) + mapCustomer('cus_01KGSR4NSX1S7Y48E6MVWPPVDP')!, // Acme Corp - admin permissions (full access) mapCustomer('cust-002')!, // Tech Solutions - user permissions (view + pay) ], }; From 45e02dbc9bd6632a5f3e782090155326a5fd52b3 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Thu, 12 Feb 2026 12:19:12 +0100 Subject: [PATCH 02/27] feat: introduce support for customers, payments, and checkout modules - Added models, services, and mappers for customers, payments, and checkout. - Enhanced carts and orders to support additional fields like email and payment session. - Integrated mocked module functionality for new services. - Refined Medusa.js mappings for customer addresses and shipping options. --- apps/api-harmonization/src/app.config.ts | 6 + apps/api-harmonization/src/app.module.ts | 9 + .../integrations/src/models/checkout.ts | 9 + .../integrations/src/models/customers.ts | 9 + .../configs/integrations/src/models/index.ts | 3 + .../integrations/src/models/payments.ts | 9 + packages/framework/src/api-config.ts | 21 + packages/framework/src/index.ts | 3 + .../src/modules/carts/carts.model.ts | 1 + .../src/modules/carts/carts.request.ts | 30 +- .../src/modules/carts/carts.service.ts | 19 + .../modules/checkout/checkout.controller.ts | 68 ++++ .../src/modules/checkout/checkout.model.ts | 31 ++ .../src/modules/checkout/checkout.module.ts | 29 ++ .../src/modules/checkout/checkout.request.ts | 65 ++++ .../src/modules/checkout/checkout.service.ts | 57 +++ .../framework/src/modules/checkout/index.ts | 5 + .../modules/customers/customers.controller.ts | 47 +++ .../src/modules/customers/customers.model.ts | 13 + .../src/modules/customers/customers.module.ts | 29 ++ .../modules/customers/customers.request.ts | 29 ++ .../modules/customers/customers.service.ts | 32 ++ .../framework/src/modules/customers/index.ts | 5 + .../src/modules/orders/orders.model.ts | 1 + .../framework/src/modules/payments/index.ts | 5 + .../modules/payments/payments.controller.ts | 42 ++ .../src/modules/payments/payments.model.ts | 28 ++ .../src/modules/payments/payments.module.ts | 29 ++ .../src/modules/payments/payments.request.ts | 29 ++ .../src/modules/payments/payments.service.ts | 30 ++ .../framework/src/utils/models/address.ts | 2 + .../integrations/medusajs/src/integration.ts | 20 +- .../src/modules/carts/carts.mapper.ts | 25 +- .../src/modules/carts/carts.service.ts | 305 ++++++++++++--- .../src/modules/checkout/checkout.mapper.ts | 117 ++++++ .../src/modules/checkout/checkout.service.ts | 365 ++++++++++++++++++ .../medusajs/src/modules/checkout/index.ts | 7 + .../src/modules/customers/customers.mapper.ts | 59 +++ .../modules/customers/customers.service.ts | 265 +++++++++++++ .../medusajs/src/modules/customers/index.ts | 7 + .../medusajs/src/modules/index.ts | 3 + .../src/modules/medusajs/medusajs.service.ts | 58 ++- .../src/modules/orders/orders.mapper.spec.ts | 6 +- .../src/modules/orders/orders.mapper.ts | 14 +- .../src/modules/orders/orders.service.spec.ts | 2 - .../src/modules/orders/orders.service.ts | 72 ++-- .../medusajs/src/modules/payments/index.ts | 7 + .../src/modules/payments/payments.mapper.ts | 67 ++++ .../src/modules/payments/payments.service.ts | 216 +++++++++++ .../src/modules/products/products.mapper.ts | 266 ++++++++++--- .../src/modules/products/products.service.ts | 101 +++-- .../src/modules/products/response.types.ts | 1 + .../src/modules/resources/resources.mapper.ts | 2 + packages/integrations/mocked/prisma/seed.ts | 2 +- .../integrations/mocked/src/integration.ts | 19 +- .../mocked/src/modules/carts/carts.mapper.ts | 44 ++- .../mocked/src/modules/carts/carts.service.ts | 118 +++++- .../src/modules/checkout/checkout.mapper.ts | 102 +++++ .../src/modules/checkout/checkout.service.ts | 244 ++++++++++++ .../mocked/src/modules/checkout/index.ts | 7 + .../src/modules/customers/customers.mapper.ts | 52 +++ .../modules/customers/customers.service.ts | 164 ++++++++ .../mocked/src/modules/customers/index.ts | 8 + .../modules/customers/mocks/addresses.mock.ts | 68 ++++ .../integrations/mocked/src/modules/index.ts | 3 + .../src/modules/orders/orders.mapper.ts | 61 ++- .../organizations/organizations.mapper.ts | 2 +- .../mocked/src/modules/payments/index.ts | 8 + .../modules/payments/mocks/providers.mock.ts | 38 ++ .../src/modules/payments/payments.mapper.ts | 56 +++ .../src/modules/payments/payments.service.ts | 88 +++++ .../src/modules/resources/resources.mapper.ts | 4 +- .../src/modules/users/customers.mapper.ts | 2 +- .../mocked/src/modules/users/users.mapper.ts | 2 +- 74 files changed, 3572 insertions(+), 200 deletions(-) create mode 100644 packages/configs/integrations/src/models/checkout.ts create mode 100644 packages/configs/integrations/src/models/customers.ts create mode 100644 packages/configs/integrations/src/models/payments.ts create mode 100644 packages/framework/src/modules/checkout/checkout.controller.ts create mode 100644 packages/framework/src/modules/checkout/checkout.model.ts create mode 100644 packages/framework/src/modules/checkout/checkout.module.ts create mode 100644 packages/framework/src/modules/checkout/checkout.request.ts create mode 100644 packages/framework/src/modules/checkout/checkout.service.ts create mode 100644 packages/framework/src/modules/checkout/index.ts create mode 100644 packages/framework/src/modules/customers/customers.controller.ts create mode 100644 packages/framework/src/modules/customers/customers.model.ts create mode 100644 packages/framework/src/modules/customers/customers.module.ts create mode 100644 packages/framework/src/modules/customers/customers.request.ts create mode 100644 packages/framework/src/modules/customers/customers.service.ts create mode 100644 packages/framework/src/modules/customers/index.ts create mode 100644 packages/framework/src/modules/payments/index.ts create mode 100644 packages/framework/src/modules/payments/payments.controller.ts create mode 100644 packages/framework/src/modules/payments/payments.model.ts create mode 100644 packages/framework/src/modules/payments/payments.module.ts create mode 100644 packages/framework/src/modules/payments/payments.request.ts create mode 100644 packages/framework/src/modules/payments/payments.service.ts create mode 100644 packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts create mode 100644 packages/integrations/medusajs/src/modules/checkout/checkout.service.ts create mode 100644 packages/integrations/medusajs/src/modules/checkout/index.ts create mode 100644 packages/integrations/medusajs/src/modules/customers/customers.mapper.ts create mode 100644 packages/integrations/medusajs/src/modules/customers/customers.service.ts create mode 100644 packages/integrations/medusajs/src/modules/customers/index.ts create mode 100644 packages/integrations/medusajs/src/modules/payments/index.ts create mode 100644 packages/integrations/medusajs/src/modules/payments/payments.mapper.ts create mode 100644 packages/integrations/medusajs/src/modules/payments/payments.service.ts create mode 100644 packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts create mode 100644 packages/integrations/mocked/src/modules/checkout/checkout.service.ts create mode 100644 packages/integrations/mocked/src/modules/checkout/index.ts create mode 100644 packages/integrations/mocked/src/modules/customers/customers.mapper.ts create mode 100644 packages/integrations/mocked/src/modules/customers/customers.service.ts create mode 100644 packages/integrations/mocked/src/modules/customers/index.ts create mode 100644 packages/integrations/mocked/src/modules/customers/mocks/addresses.mock.ts create mode 100644 packages/integrations/mocked/src/modules/payments/index.ts create mode 100644 packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts create mode 100644 packages/integrations/mocked/src/modules/payments/payments.mapper.ts create mode 100644 packages/integrations/mocked/src/modules/payments/payments.service.ts diff --git a/apps/api-harmonization/src/app.config.ts b/apps/api-harmonization/src/app.config.ts index 7f46bf5de..8da0e5f9d 100644 --- a/apps/api-harmonization/src/app.config.ts +++ b/apps/api-harmonization/src/app.config.ts @@ -5,10 +5,13 @@ import { CMS, Cache, Carts, + Checkout, + Customers, Invoices, Notifications, Orders, Organizations, + Payments, Products, Resources, Search, @@ -34,6 +37,9 @@ export const AppConfig: ApiConfig = { 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 4a22a9d66..2c5846418 100644 --- a/apps/api-harmonization/src/app.module.ts +++ b/apps/api-harmonization/src/app.module.ts @@ -14,10 +14,13 @@ import { CMS, Cache, Carts, + Checkout, + Customers, Invoices, Notifications, Orders, Organizations, + Payments, Products, Resources, Search, @@ -81,6 +84,9 @@ 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({ @@ -109,6 +115,9 @@ export const AuthModuleBaseModule = AuthModule.Module.register(AppConfig); ProductsBaseModule, OrdersBaseModule, CartsBaseModule, + CustomersBaseModule, + PaymentsBaseModule, + CheckoutBaseModule, AuthModuleBaseModule, PageModule.register(AppConfig), diff --git a/packages/configs/integrations/src/models/checkout.ts b/packages/configs/integrations/src/models/checkout.ts new file mode 100644 index 000000000..96661e7e5 --- /dev/null +++ b/packages/configs/integrations/src/models/checkout.ts @@ -0,0 +1,9 @@ +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; diff --git a/packages/configs/integrations/src/models/customers.ts b/packages/configs/integrations/src/models/customers.ts new file mode 100644 index 000000000..008383b5b --- /dev/null +++ b/packages/configs/integrations/src/models/customers.ts @@ -0,0 +1,9 @@ +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; diff --git a/packages/configs/integrations/src/models/index.ts b/packages/configs/integrations/src/models/index.ts index 94388ff09..8cf0413f4 100644 --- a/packages/configs/integrations/src/models/index.ts +++ b/packages/configs/integrations/src/models/index.ts @@ -3,11 +3,14 @@ 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..955de46de --- /dev/null +++ b/packages/configs/integrations/src/models/payments.ts @@ -0,0 +1,9 @@ +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; diff --git a/packages/framework/src/api-config.ts b/packages/framework/src/api-config.ts index 8b2fd9cdb..5e72e2661 100644 --- a/packages/framework/src/api-config.ts +++ b/packages/framework/src/api-config.ts @@ -7,10 +7,13 @@ import { CMS, Cache, Carts, + Checkout, + Customers, Invoices, Notifications, Orders, Organizations, + Payments, Products, Resources, Search, @@ -105,6 +108,24 @@ export interface ApiConfig { 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 79223f5fd..4aba415ca 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -17,3 +17,6 @@ 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.model.ts b/packages/framework/src/modules/carts/carts.model.ts index 91aeb493d..3d02b7b69 100644 --- a/packages/framework/src/modules/carts/carts.model.ts +++ b/packages/framework/src/modules/carts/carts.model.ts @@ -69,6 +69,7 @@ export class Cart { promotions?: Promotion[]; metadata?: Record; notes?: string; + paymentSessionId?: string; // Reference to active payment session } export type Carts = Pagination.Paginated; diff --git a/packages/framework/src/modules/carts/carts.request.ts b/packages/framework/src/modules/carts/carts.request.ts index ca6580928..7204f01d1 100644 --- a/packages/framework/src/modules/carts/carts.request.ts +++ b/packages/framework/src/modules/carts/carts.request.ts @@ -1,5 +1,5 @@ import { CartType, PaymentMethodType } from './carts.model'; -import { Price } from '@/utils/models'; +import { Address, Price } from '@/utils/models'; import { PaginationQuery } from '@/utils/models/pagination'; // Cart retrieval @@ -84,3 +84,31 @@ export class RemovePromotionParams { cartId!: string; promotionId!: 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; + guestEmail?: string; // For guest checkout +} + +// Shipping method operations +export class AddShippingMethodParams { + cartId!: string; +} + +export class AddShippingMethodBody { + shippingOptionId!: string; // Shipping option ID from getShippingOptions() +} diff --git a/packages/framework/src/modules/carts/carts.service.ts b/packages/framework/src/modules/carts/carts.service.ts index c2de5376e..18fa346d7 100644 --- a/packages/framework/src/modules/carts/carts.service.ts +++ b/packages/framework/src/modules/carts/carts.service.ts @@ -47,4 +47,23 @@ export abstract class CartService { ): Observable; abstract getCurrentCart(authorization?: string): Observable; + + abstract prepareCheckout( + params: Carts.Request.PrepareCheckoutParams, + authorization?: string, + ): Observable; + + // Update cart addresses (shipping and/or billing) + abstract updateCartAddresses( + params: Carts.Request.UpdateCartAddressesParams, + data: Carts.Request.UpdateCartAddressesBody, + authorization?: string, + ): Observable; + + // Add shipping method to cart + abstract addShippingMethod( + params: Carts.Request.AddShippingMethodParams, + data: Carts.Request.AddShippingMethodBody, + authorization?: string, + ): Observable; } 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..318ecbb7c --- /dev/null +++ b/packages/framework/src/modules/checkout/checkout.controller.ts @@ -0,0 +1,68 @@ +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') + setupAddresses( + @Param() params: Request.SetupAddressesParams, + @Body() body: Request.SetupAddressesBody, + @Headers() headers: AppHeaders, + ) { + return this.checkoutService.setupAddresses(params, body, headers.authorization); + } + + @Post(':cartId/shipping-method') + setupShippingMethod( + @Param() params: Request.SetupShippingMethodParams, + @Body() body: Request.SetupShippingMethodBody, + @Headers() headers: AppHeaders, + ) { + return this.checkoutService.setupShippingMethod(params, body, headers.authorization); + } + + @Post(':cartId/payment') + setupPayment( + @Param() params: Request.SetupPaymentParams, + @Body() body: Request.SetupPaymentBody, + @Headers() headers: AppHeaders, + ) { + return this.checkoutService.setupPayment(params, body, headers.authorization); + } + + @Get(':cartId/shipping-options') + getShippingOptions(@Param() params: Request.GetShippingOptionsParams, @Headers() headers: AppHeaders) { + return this.checkoutService.getShippingOptions(params, headers.authorization); + } + + @Get(':cartId/summary') + getCheckoutSummary(@Param() params: Request.GetCheckoutSummaryParams, @Headers() headers: AppHeaders) { + return this.checkoutService.getCheckoutSummary(params, 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..631d9a77b --- /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; + guestEmail?: string; // Required 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..edb769933 --- /dev/null +++ b/packages/framework/src/modules/checkout/checkout.request.ts @@ -0,0 +1,65 @@ +import { Address } from '@/utils/models'; + +export class SetupAddressesParams { + cartId!: string; +} + +export class SetupAddressesBody { + 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; + guestEmail?: string; // For guest checkout +} + +export class SetupShippingMethodParams { + cartId!: string; +} + +export class SetupShippingMethodBody { + shippingOptionId!: string; // Shipping option ID from getShippingOptions() +} + +export class SetupPaymentParams { + cartId!: string; +} + +export class SetupPaymentBody { + providerId!: string; + metadata?: Record; +} + +export class GetShippingOptionsParams { + cartId!: string; +} + +export class GetCheckoutSummaryParams { + cartId!: string; +} + +export class PlaceOrderParams { + cartId!: string; +} + +export class PlaceOrderBody { + // Optional - can be empty if all data already in cart + // Allows frontend to confirm before placing + guestEmail?: 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; + notes?: string; + guestEmail?: 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..73124215f --- /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[]) {} + + // Setup addresses (shipping and/or billing) + abstract setupAddresses( + params: Checkout.Request.SetupAddressesParams, + data: Checkout.Request.SetupAddressesBody, + authorization?: string, + ): Observable; + + // Setup shipping method + abstract setupShippingMethod( + params: Checkout.Request.SetupShippingMethodParams, + data: Checkout.Request.SetupShippingMethodBody, + authorization?: string, + ): Observable; + + // Setup payment (independent action, can be called before or after shipping) + abstract setupPayment( + params: Checkout.Request.SetupPaymentParams, + data: Checkout.Request.SetupPaymentBody, + 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 + 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..14166dce8 --- /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, 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..8d9847455 --- /dev/null +++ b/packages/framework/src/modules/payments/payments.model.ts @@ -0,0 +1,28 @@ +import { Pagination } from '@/utils/models'; + +export type PaymentProviderType = 'STRIPE' | 'PAYPAL' | 'ADYEN' | 'SYSTEM' | 'OTHER'; + +export class PaymentProvider { + id!: string; + name!: string; + type!: PaymentProviderType; + 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..a0a95d752 --- /dev/null +++ b/packages/framework/src/modules/payments/payments.request.ts @@ -0,0 +1,29 @@ +export class GetProvidersParams { + regionId!: string; +} + +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/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 f9e251baf..f68b441eb 100644 --- a/packages/integrations/medusajs/src/integration.ts +++ b/packages/integrations/medusajs/src/integration.ts @@ -1,10 +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'; @@ -29,6 +32,21 @@ export const Config: Partial = { 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], + }, + checkout: { + name: 'medusajs', + service: CheckoutService, imports: [MedusaJsModule, Auth.Module], }, }; diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index a3da3fb53..e7c19c6a8 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -12,8 +12,13 @@ export const mapCarts = ( }; }; -export const mapCart = (cart: HttpTypes.StoreCart, defaultCurrency: string): Carts.Model.Cart => { - const currency = (cart.currency_code as Models.Price.Currency) ?? (defaultCurrency as Models.Price.Currency); +export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Carts.Model.Cart => { + if (!cart.currency_code) { + throw new Error(`Cart ${cart.id} has no currency code`); + } + const currency = cart.currency_code as Models.Price.Currency; + + console.log('cart.customer_id', cart.customer_id); return { id: cart.id, @@ -37,7 +42,7 @@ export const mapCart = (cart: HttpTypes.StoreCart, defaultCurrency: string): Car shippingAddress: mapAddress(cart.shipping_address), billingAddress: mapAddress(cart.billing_address), shippingMethod: cart.shipping_methods?.[0] ? mapShippingMethod(cart.shipping_methods[0], currency) : undefined, - paymentMethod: undefined, // Map from payment collection if available + paymentMethod: mapPaymentMethodFromMetadata((cart.metadata as Record) ?? {}), promotions: mapPromotions(cart), metadata: (cart.metadata as Record) ?? {}, notes: undefined, @@ -85,6 +90,8 @@ const mapProduct = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.Cu const mapAddress = (address?: HttpTypes.StoreCartAddress | null): Models.Address.Address | undefined => { if (!address) return undefined; return { + firstName: address.first_name, + lastName: address.last_name, country: address.country_code ?? '', district: address.province ?? '', region: address.province ?? '', @@ -97,6 +104,18 @@ const mapAddress = (address?: HttpTypes.StoreCartAddress | null): Models.Address }; }; +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, + type: (stored.type as Carts.Model.PaymentMethodType) ?? 'OTHER', + }; +}; + const mapShippingMethod = ( method: HttpTypes.StoreCartShippingMethod, currency: Models.Price.Currency, diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index c10d0c149..39bec1aaa 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -1,5 +1,5 @@ import Medusa from '@medusajs/js-sdk'; -import { HttpService } from '@nestjs/axios'; +import { HttpTypes } from '@medusajs/types'; import { BadRequestException, Inject, @@ -9,14 +9,15 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Observable, catchError, from, map, of, switchMap, throwError } from 'rxjs'; +import { Observable, catchError, forkJoin, from, map, of, switchMap, throwError } from 'rxjs'; import { LoggerService } from '@o2s/utils.logger'; -import { Auth, Carts } from '@o2s/framework/modules'; +import { Auth, Carts, Customers } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; +import { mapAddressToMedusa } from '../customers/customers.mapper'; import { handleHttpError } from '../utils/handle-http-error'; import { mapCart } from './carts.mapper'; @@ -28,10 +29,10 @@ export class CartsService extends Carts.Service { constructor( private readonly config: ConfigService, - protected httpClient: HttpService, @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(); @@ -43,8 +44,10 @@ export class CartsService extends Carts.Service { } getCart(params: Carts.Request.GetCartParams, authorization?: string): Observable { - return from(this.sdk.store.cart.retrieve(params.id, {}, this.getHeaders())).pipe( - map((response) => { + return from( + this.sdk.store.cart.retrieve(params.id, {}, this.medusaJsService.getStoreApiHeaders(authorization)), + ).pipe( + map((response: HttpTypes.StoreCartResponse) => { const cart = mapCart(response.cart, this.defaultCurrency); // Verify ownership for customer carts @@ -60,7 +63,7 @@ export class CartsService extends Carts.Service { }), catchError((error) => { if (error.status === 404) { - return of(undefined); + throw new NotFoundException('Cart not found'); } return handleHttpError(error); }), @@ -76,7 +79,7 @@ export class CartsService extends Carts.Service { ); } - createCart(data: Carts.Request.CreateCartBody, _authorization?: string): Observable { + createCart(data: Carts.Request.CreateCartBody, authorization?: string): Observable { return from( this.sdk.store.cart.create( { @@ -85,10 +88,10 @@ export class CartsService extends Carts.Service { metadata: data.metadata, }, {}, - this.getHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( - map((response) => mapCart(response.cart, this.defaultCurrency)), + map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); } @@ -96,7 +99,7 @@ export class CartsService extends Carts.Service { updateCart( params: Carts.Request.UpdateCartParams, data: Carts.Request.UpdateCartBody, - _authorization?: string, + authorization?: string, ): Observable { return from( this.sdk.store.cart.update( @@ -106,10 +109,10 @@ export class CartsService extends Carts.Service { metadata: data.metadata, }, {}, - this.getHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( - map((response) => mapCart(response.cart, this.defaultCurrency)), + map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); } @@ -130,8 +133,10 @@ export class CartsService extends Carts.Service { // If cartId provided, use it (after verifying access) if (data.cartId) { const cartId = data.cartId; // Store for type narrowing - return from(this.sdk.store.cart.retrieve(cartId, {}, this.getHeaders())).pipe( - switchMap((response) => { + return from( + this.sdk.store.cart.retrieve(cartId, {}, this.medusaJsService.getStoreApiHeaders(authorization)), + ).pipe( + switchMap((response: HttpTypes.StoreCartResponse) => { const cart = mapCart(response.cart, this.defaultCurrency); // Verify ownership for customer carts @@ -149,11 +154,11 @@ export class CartsService extends Carts.Service { metadata: data.metadata, }, {}, - this.getHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), ), ); }), - map((addResponse) => mapCart(addResponse.cart, this.defaultCurrency)), + map((addResponse: HttpTypes.StoreCartResponse) => mapCart(addResponse.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); } @@ -178,13 +183,20 @@ export class CartsService extends Carts.Service { } // For guests, create a new cart - return this.createCartAndAddItem(data.currency, data.variantId!, data.quantity, data.regionId, data.metadata); + return this.createCartAndAddItem( + data.currency, + data.variantId!, + data.quantity, + data.regionId, + data.metadata, + authorization, + ); } updateCartItem( params: Carts.Request.UpdateCartItemParams, data: Carts.Request.UpdateCartItemBody, - _authorization: string | undefined, + authorization: string | undefined, ): Observable { return from( this.sdk.store.cart.updateLineItem( @@ -195,17 +207,23 @@ export class CartsService extends Carts.Service { metadata: data.metadata, }, {}, - this.getHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( - map((response) => mapCart(response.cart, this.defaultCurrency)), + 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, this.getHeaders())).pipe( - map((response) => { + removeCartItem(params: Carts.Request.RemoveCartItemParams, authorization?: string): Observable { + return from( + this.sdk.store.cart.deleteLineItem( + params.cartId, + params.itemId, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StoreLineItemDeleteResponse) => { if (!response.parent) { throw new NotFoundException('Cart not found after item removal'); } @@ -218,7 +236,7 @@ export class CartsService extends Carts.Service { applyPromotion( params: Carts.Request.ApplyPromotionParams, data: Carts.Request.ApplyPromotionBody, - _authorization: string | undefined, + authorization: string | undefined, ): Observable { return from( this.sdk.store.cart.update( @@ -227,22 +245,21 @@ export class CartsService extends Carts.Service { promo_codes: [data.code], }, {}, - this.getHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( - map((response) => mapCart(response.cart, this.defaultCurrency)), + map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); } - removePromotion( - params: Carts.Request.RemovePromotionParams, - _authorization?: string, - ): Observable { + removePromotion(params: Carts.Request.RemovePromotionParams, authorization?: string): Observable { // In Medusa v2, removing promotions requires updating the cart // with the remaining promo codes (excluding the one to remove) - return from(this.sdk.store.cart.retrieve(params.cartId, {}, this.getHeaders())).pipe( - switchMap((response) => { + return from( + this.sdk.store.cart.retrieve(params.cartId, {}, this.medusaJsService.getStoreApiHeaders(authorization)), + ).pipe( + switchMap((response: HttpTypes.StoreCartResponse) => { const cart = response.cart; // Filter out the promotion to remove const remainingCodes = @@ -258,11 +275,11 @@ export class CartsService extends Carts.Service { promo_codes: remainingCodes, }, {}, - this.getHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), ), ); }), - map((response) => mapCart(response.cart, this.defaultCurrency)), + map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); } @@ -276,12 +293,40 @@ export class CartsService extends Carts.Service { ); } + 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, variantId: string, quantity: number, regionId?: string, metadata?: Record, + authorization?: string, ): Observable { return from( this.sdk.store.cart.create( @@ -291,10 +336,10 @@ export class CartsService extends Carts.Service { metadata, }, {}, - this.getHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( - switchMap((createResponse) => + switchMap((createResponse: HttpTypes.StoreCartResponse) => from( this.sdk.store.cart.createLineItem( createResponse.cart.id, @@ -304,18 +349,192 @@ export class CartsService extends Carts.Service { metadata, }, {}, - this.getHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), ), ), ), - map((addResponse) => mapCart(addResponse.cart, this.defaultCurrency)), + 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 = authorization + ? this.medusaJsService.getStoreApiHeaders(authorization) + : 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 + const metadata = this.buildCartMetadata(data.notes, data.guestEmail, cart.metadata); + + // Build cart update payload + const cartUpdate: Partial = { + metadata, + }; + + // 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, {}, 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 }, + {}, + 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)), ); } - private getHeaders(): Record { - return { - 'x-publishable-api-key': this.medusaJsService.getPublishableKey(), - }; + /** + * Resolves the billing address from the request data. + * Returns `null` if no billing address is specified (caller should fall back to shipping address). + */ + private resolveBillingAddress( + data: Carts.Request.UpdateCartAddressesBody, + authorization: string | undefined, + ): Observable { + if (data.billingAddressId && authorization) { + return this.customersService.getAddress({ id: data.billingAddressId }, authorization).pipe( + map((billingAddress) => { + if (!billingAddress) { + throw new NotFoundException(`Address with ID ${data.billingAddressId} not found`); + } + return mapAddressToMedusa(billingAddress.address); + }), + ); + } + + if (data.billingAddress) { + return of(mapAddressToMedusa(data.billingAddress)); + } + + return of(null); + } + + /** + * Builds cart metadata immutably by merging optional notes and guestEmail + * into existing metadata without mutating any arguments. + */ + private buildCartMetadata( + notes: string | undefined, + guestEmail: string | undefined, + existingMetadata?: Record, + ): Record { + const metadata: Record = { ...(existingMetadata || {}) }; + if (notes !== undefined) { + metadata.notes = notes; + } + if (guestEmail) { + metadata.guestEmail = guestEmail; + } + return metadata; } } 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..ff622bc99 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts @@ -0,0 +1,117 @@ +import { HttpTypes } from '@medusajs/types'; +import { BadRequestException } from '@nestjs/common'; + +import { Carts, Checkout, Models, Orders, Payments } from '@o2s/framework/modules'; + +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, + guestEmail: cart.metadata?.guestEmail as string | undefined, + }; +} + +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?.toUpperCase(); + if (!currencyCode) { + throw new BadRequestException(`Shipping option ${option.id} has no currency information`); + } + + const amountWithoutTax = calculatedPrice?.calculated_amount_without_tax ?? amount; + + const currency = currencyCode as Models.Price.Currency; + + 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.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts new file mode 100644 index 000000000..4a075620c --- /dev/null +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts @@ -0,0 +1,365 @@ +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 { Auth, Carts, Checkout, Customers, Orders, Payments } from '@o2s/framework/modules'; + +import { Service as MedusaJsService } from '@/modules/medusajs'; + +import { handleHttpError } from '../utils/handle-http-error'; + +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 authService: Auth.Service, + private readonly cartsService: Carts.Service, + private readonly customersService: Customers.Service, + private readonly paymentsService: Payments.Service, + private readonly ordersService: Orders.Service, + ) { + super(); + this.sdk = this.medusaJsService.getSdk(); + this.defaultCurrency = this.config.get('DEFAULT_CURRENCY') || ''; + } + + setupAddresses( + params: Checkout.Request.SetupAddressesParams, + data: Checkout.Request.SetupAddressesBody, + 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)), + ); + } + + setupShippingMethod( + params: Checkout.Request.SetupShippingMethodParams, + data: Checkout.Request.SetupShippingMethodBody, + 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)), + ); + } + + setupPayment( + params: Checkout.Request.SetupPaymentParams, + data: Checkout.Request.SetupPaymentBody, + authorization: string | undefined, + ): Observable { + return this.paymentsService + .createSession( + { + cartId: params.cartId, + providerId: data.providerId, + returnUrl: 'https://example.com/checkout/return', + cancelUrl: 'https://example.com/checkout/cancel', + 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 paymentSessionId = cart.metadata?.paymentSessionId as string | undefined; + + if (paymentSessionId) { + return this.paymentsService + .getSession({ id: paymentSessionId }, authorization) + .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')); + } + + // Store guest email in cart metadata if provided + const guestEmail = data?.guestEmail || (cart.metadata?.guestEmail as string | undefined); + if (guestEmail) { + return this.cartsService + .updateCart( + { id: params.cartId }, + { metadata: { ...cart.metadata, guestEmail } }, + authorization, + ) + .pipe( + switchMap(() => this.completeCartAndCreateOrder(params.cartId, guestEmail, authorization)), + ); + } + + return this.completeCartAndCreateOrder(params.cartId, guestEmail, authorization); + }), + ); + } + + private completeCartAndCreateOrder( + cartId: string, + guestEmail: 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) => { + const orderId = response.type === 'order' ? response.order?.id : undefined; + if (!orderId) { + return throwError(() => new BadRequestException('Failed to create order from cart')); + } + + // Get the created order + return this.ordersService.getOrder({ id: orderId }, authorization).pipe( + switchMap((order) => { + if (!order) { + return throwError(() => new NotFoundException(`Order with ID ${orderId} not found`)); + } + + // Update order with guest email if provided + if (guestEmail && !order.email) { + // Note: In a real implementation, you'd update the order with email + // For now, we'll just use the order as-is + } + + // Note: After successful cart completion (type === 'order'), the cart metadata + // is no longer available in the response. Payment redirect URL should be + // obtained from the payment session before cart completion. + 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 = authorization + ? this.medusaJsService.getStoreApiHeaders(authorization) + : 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: option }); + }), + ), + ); + + // Step 4: Wait for all calculations, then merge results + return forkJoin(calculateObservables).pipe( + map((calculatedResults) => { + const calculatedMap = new Map(); + calculatedResults.forEach((result) => { + 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.setupAddresses( + { cartId: params.cartId }, + { + shippingAddressId: data.shippingAddressId, + shippingAddress: data.shippingAddress, + billingAddressId: data.billingAddressId, + billingAddress: data.billingAddress, + notes: data.notes, + guestEmail: data.guestEmail, + }, + authorization, + ).pipe( + // Setup shipping method if provided + switchMap(() => + data.shippingMethodId + ? this.setupShippingMethod( + { cartId: params.cartId }, + { shippingOptionId: data.shippingMethodId }, + authorization, + ) + : of(null), + ), + switchMap(() => + // Setup payment + this.setupPayment( + { cartId: params.cartId }, + { + providerId: data.paymentProviderId, + metadata: data.metadata, + }, + authorization, + ), + ), + switchMap(() => + // Place order + this.placeOrder( + { cartId: params.cartId }, + { + guestEmail: data.guestEmail, + }, + 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.ts b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts new file mode 100644 index 000000000..d8f1e97e0 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts @@ -0,0 +1,59 @@ +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 as unknown as Record).is_default === true || + medusaAddress.is_default_shipping || + medusaAddress.is_default_billing, + address: { + firstName: medusaAddress.first_name, + lastName: medusaAddress.last_name, + country: medusaAddress.country_code || '', + streetName: medusaAddress.address_1 || '', + streetNumber: medusaAddress.address_2, + city: medusaAddress.city || '', + postalCode: medusaAddress.postal_code || '', + region: medusaAddress.province, + phone: medusaAddress.phone, + } as Models.Address.Address, + 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 || 'Customer').trim(), + last_name: (address.lastName || 'Name').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.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.ts new file mode 100644 index 000000000..85727775f --- /dev/null +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.ts @@ -0,0 +1,265 @@ +import Medusa from '@medusajs/js-sdk'; +import { HttpTypes } from '@medusajs/types'; +import { Inject, Injectable, 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) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + throw 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) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + throw 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) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + throw 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 || []; + // The newly created address is typically the last one in the list + const createdAddress = addresses[addresses.length - 1]; + if (!createdAddress) { + return throwError(() => new Error('Failed to create address')); + } + + 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) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + throw 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) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + throw 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) { + throw new UnauthorizedException('Authentication required'); + } + + const customerId = this.authService.getCustomerId(authorization); + if (!customerId) { + throw new UnauthorizedException('Invalid authentication'); + } + + return from( + this.sdk.store.customer.updateAddress( + params.id, + { is_default_shipping: true } as unknown as HttpTypes.StoreUpdateCustomerAddress, + {}, + this.medusaJsService.getStoreApiHeaders(authorization), + ), + ).pipe( + map((response: HttpTypes.StoreCustomerResponse) => { + const customer = response.customer; + const addresses = customer.addresses || []; + const updatedAddress = addresses.find((a) => a.id === params.id); + if (!updatedAddress) { + throw new NotFoundException(`Address with ID ${params.id} not found`); + } + return 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 0e787698c..0628cda51 100644 --- a/packages/integrations/medusajs/src/modules/index.ts +++ b/packages/integrations/medusajs/src/modules/index.ts @@ -2,4 +2,7 @@ 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.ts b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts index 254145861..74659722c 100644 --- a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts +++ b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts @@ -2,6 +2,36 @@ import Medusa from '@medusajs/js-sdk'; import { Global, Injectable } 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 { @@ -40,7 +70,10 @@ export class MedusaJsService { // debug: this.logLevel === 'debug', debug: true, publishableKey: this._medusaPublishableApiKey, - apiKey: this._medusaAdminApiKey, + // apiKey: this._medusaAdminApiKey, + auth: { + type: 'jwt', + }, }); this._medusaAdminApiKeyEncoded = Buffer.from(this._medusaAdminApiKey).toString('base64'); } @@ -70,10 +103,31 @@ 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 = {}; + 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..24c2d222c 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); diff --git a/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts b/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts index c2734382c..46b99e2d5 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts @@ -3,14 +3,14 @@ import { NotFoundException } from '@nestjs/common'; import { Models, Orders, Products } from '@o2s/framework/modules'; -export const mapOrders = (orders: HttpTypes.AdminOrderListResponse, defaultCurrency: string): Orders.Model.Orders => { +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 => { return { id: order.id, total: mapPrice(order.total, order?.currency_code ?? defaultCurrency) as Models.Price.Price, @@ -38,7 +38,7 @@ export const mapOrder = (order: HttpTypes.AdminOrder, defaultCurrency: string): }; }; -const mapOrderItem = (item: HttpTypes.AdminOrderLineItem, currency: string): Orders.Model.OrderItem => { +const mapOrderItem = (item: HttpTypes.StoreOrderLineItem, currency: string): Orders.Model.OrderItem => { return { id: item.id, productId: item.variant_id || '', @@ -54,7 +54,7 @@ const mapOrderItem = (item: HttpTypes.AdminOrderLineItem, currency: string): Ord const mapProduct = ( unitPrice: number, currency: string, - item?: HttpTypes.AdminOrderLineItem, + item?: HttpTypes.StoreOrderLineItem, ): Products.Model.Product => { if (!item) throw new NotFoundException('Product not found'); @@ -78,9 +78,11 @@ const mapProduct = ( }; }; -const mapAddress = (address?: HttpTypes.AdminOrderAddress | null): Models.Address.Address | undefined => { +const mapAddress = (address?: HttpTypes.StoreOrderAddress | null): Models.Address.Address | undefined => { if (!address) return undefined; return { + firstName: address.first_name, + lastName: address.last_name, country: address.country_code || '', district: address.province || '', region: address.province || '', @@ -94,7 +96,7 @@ const mapAddress = (address?: HttpTypes.AdminOrderAddress | null): Models.Addres }; const mapShippingMethod = ( - method: HttpTypes.AdminOrderShippingMethod, + method: HttpTypes.StoreOrderShippingMethod, currency: string, ): Orders.Model.ShippingMethod => { return { 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..9d1af57d8 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts @@ -58,7 +58,6 @@ describe('OrdersService', () => { 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 +72,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, diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.ts index db046d109..4e969fc6f 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 { ConfigService } from '@nestjs/config'; -import { Observable, catchError, from } from 'rxjs'; +import { Observable, catchError, from, map } from 'rxjs'; import { LoggerService } from '@o2s/utils.logger'; @@ -15,6 +14,13 @@ 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, @@ -40,6 +45,12 @@ export class OrdersService extends Orders.Service { } } + /** + * 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.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts new file mode 100644 index 000000000..19e51c159 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts @@ -0,0 +1,67 @@ +import { HttpTypes } from '@medusajs/types'; + +import { Payments } from '@o2s/framework/modules'; + +export function mapPaymentProvider(medusaProvider: HttpTypes.StorePaymentProvider): Payments.Model.PaymentProvider { + return { + id: medusaProvider.id, + name: medusaProvider.id, // Medusa doesn't provide a name, use ID + type: mapProviderType(medusaProvider.id), + isEnabled: true, // Assume enabled if returned by API + requiresRedirect: medusaProvider.id.includes('stripe') || medusaProvider.id.includes('paypal'), + config: {}, + }; +} + +export function mapPaymentProviders( + medusaProviders: HttpTypes.StorePaymentProvider[], + limit = 10, + offset = 0, +): Payments.Model.PaymentProviders { + const providers = medusaProviders.map(mapPaymentProvider); + + return { + data: providers.slice(offset, offset + limit), + total: providers.length, + }; +} + +export function mapPaymentSession( + medusaSession: HttpTypes.StorePaymentSession, + cartId: string, +): Payments.Model.PaymentSession { + return { + id: medusaSession.id, + cartId, + providerId: medusaSession.provider_id, + status: mapPaymentSessionStatus(medusaSession.status), + redirectUrl: medusaSession.data?.redirect_url as string | undefined, + clientSecret: medusaSession.data?.client_secret as string | undefined, + expiresAt: (medusaSession as unknown as Record).expires_at as string | undefined, + metadata: medusaSession.data as Record | undefined, + }; +} + +function mapProviderType(providerId: string): Payments.Model.PaymentProviderType { + const id = providerId.toLowerCase(); + if (id.includes('stripe')) return 'STRIPE'; + if (id.includes('paypal')) return 'PAYPAL'; + if (id.includes('adyen')) return 'ADYEN'; + if (id.includes('system') || id.includes('manual')) return 'SYSTEM'; + return 'OTHER'; +} + +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.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.ts new file mode 100644 index 000000000..8d3066f79 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.ts @@ -0,0 +1,216 @@ +import Medusa from '@medusajs/js-sdk'; +import { HttpTypes } from '@medusajs/types'; +import { HttpService } from '@nestjs/axios'; +import { BadRequestException, Inject, Injectable, NotFoundException } 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()` + * - ⚠️ `updateSession` - Uses raw HTTP (SDK method not available) + * - ⚠️ `cancelSession` - Uses raw HTTP (SDK method not available) + * + * The `updateSession` and `cancelSession` methods use direct HTTP requests 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) { + throw 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, 10, 0); + }), + catchError((error) => { + // If endpoint doesn't exist or fails, return empty list + this.logger.warn('Failed to fetch payment providers from Medusa', error); + return of(mapPaymentProviders([], 10, 0)); + }), + ); + } + + 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 Error('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); + }), + ); + } + + getSession( + _params: Payments.Request.GetSessionParams, + _authorization: string | undefined, + ): Observable { + // Medusa Store API doesn't have a direct endpoint to get payment session by ID + // Payment sessions are accessed through payment collections + // Since we don't have the collection ID or cart ID, we return undefined + // In practice, payment sessions should be retrieved via the cart's payment collection + // This method is primarily used when we have the session ID from cart metadata + // For a complete implementation, we would need to: + // 1. Store payment_collection_id in cart metadata when creating session + // 2. Retrieve collection by ID and find session within it + // For now, return undefined as Medusa doesn't provide a direct lookup + return of(undefined); + } + + /** + * Updates a payment session using raw HTTP request. + * + * @note The Medusa.js SDK does not provide methods for updating payment sessions. + * This method uses a direct HTTP POST request to `/store/payment-sessions/{id}`. + * Once the SDK adds support for this operation, this should be migrated to use the SDK method. + * + * @see {@link https://docs.medusajs.com/api/store#payment-sessions_postpayment-sessionsid Medusa Store API - Update Payment Session} + */ + updateSession( + params: Payments.Request.UpdateSessionParams, + data: Payments.Request.UpdateSessionBody, + authorization: string | undefined, + ): Observable { + const updatePayload: Record = {}; + if (data.returnUrl) { + updatePayload['return_url'] = data.returnUrl; + } + if (data.metadata) { + updatePayload['metadata'] = data.metadata; + } + + return from( + this.httpClient + .post<{ + payment_session: { + id: string; + status: string; + provider_id: string; + data: unknown; + expires_at?: string; + }; + }>(`${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, updatePayload, { + headers: this.medusaJsService.getStoreApiHeaders(authorization), + }) + .toPromise(), + ).pipe( + map((response) => { + if (!response?.data) { + throw new Error('Failed to update payment session'); + } + // We don't have cart ID here, so we'll use empty string + // In practice, this should be retrieved from the session or stored context + const cartId = ''; + return mapPaymentSession( + response.data.payment_session as unknown as HttpTypes.StorePaymentSession, + cartId, + ); + }), + catchError((error) => { + if (error.response?.status === 404) { + return throwError(() => new NotFoundException(`Payment session with ID ${params.id} not found`)); + } + return handleHttpError(error); + }), + ); + } + + /** + * Cancels (deletes) a payment session using raw HTTP request. + * + * @note The Medusa.js SDK does not provide methods for deleting payment sessions. + * This method uses a direct HTTP DELETE request to `/store/payment-sessions/{id}`. + * Once the SDK adds support for this operation, this should be migrated to use the SDK method. + * + * @see {@link https://docs.medusajs.com/api/store#payment-sessions_deletepayment-sessionsid Medusa Store API - Delete Payment Session} + */ + cancelSession(params: Payments.Request.CancelSessionParams, authorization: string | undefined): Observable { + return from( + this.httpClient + .delete(`${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, { + headers: this.medusaJsService.getStoreApiHeaders(authorization), + }) + .toPromise(), + ).pipe( + map(() => undefined), + catchError((error) => { + if (error.response?.status === 404) { + return throwError(() => new NotFoundException(`Payment session with ID ${params.id} not found`)); + } + return handleHttpError(error); + }), + ); + } +} diff --git a/packages/integrations/medusajs/src/modules/products/products.mapper.ts b/packages/integrations/medusajs/src/modules/products/products.mapper.ts index d3bb92f3e..179201a46 100644 --- a/packages/integrations/medusajs/src/modules/products/products.mapper.ts +++ b/packages/integrations/medusajs/src/modules/products/products.mapper.ts @@ -1,4 +1,5 @@ import { HttpTypes } from '@medusajs/types'; +import { NotFoundException } from '@nestjs/common'; import { Models, Products } from '@o2s/framework/modules'; @@ -6,90 +7,267 @@ import { CompatibleServicesResponse, FeaturedServicesResponse } from '../resourc import { RelatedProductsResponse } from './response.types'; -export const mapProduct = ( - productVariant: HttpTypes.AdminProductVariant, +// Fields to extract as detailed specs from variant +const VARIANT_SPEC_FIELDS = [ + 'weight', + 'height', + 'width', + 'length', + 'material', + 'origin_country', + 'hs_code', + 'mid_code', +] as const; + +// Convert snake_case to Title Case +const formatLabel = (key: string): string => { + return key + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + +/** + * Product variant type that can be either Store or Admin API variant. + * Used by mappers that need to work with both APIs (e.g., resources module still uses Admin API). + */ +type AnyProductVariant = HttpTypes.StoreProductVariant | HttpTypes.AdminProductVariant; + +/** + * Product type that can be either Store or Admin API product. + */ +type AnyProduct = HttpTypes.StoreProduct | HttpTypes.AdminProduct; + +/** + * Extract the price from a product variant, supporting both Store and Admin API types. + * + * - Store API uses `calculated_price` (requires pricing context: region_id or country_code). + * - Admin API uses `prices` array with currency filtering. + * + * Falls back to 0 if no price is found. + */ +const getVariantPrice = ( + variant: AnyProductVariant, defaultCurrency: string, -): Products.Model.Product => { - //TODO: Find customer currency - const price = productVariant.prices?.find((price) => price.currency_code.toUpperCase() === defaultCurrency); +): { amount: number; currencyCode: string } => { + // Try Store API calculated_price first + const calculatedPrice = variant.calculated_price; + if (calculatedPrice && calculatedPrice.calculated_amount != null) { + return { + amount: calculatedPrice.calculated_amount, + currencyCode: calculatedPrice.currency_code?.toUpperCase() || defaultCurrency, + }; + } + + // Fall back to Admin API prices array + if ('prices' in variant && Array.isArray(variant.prices)) { + const adminPrices = variant.prices as Array<{ amount?: number; currency_code?: string }>; + const matchingPrice = adminPrices.find((p) => p.currency_code?.toUpperCase() === defaultCurrency); + if (matchingPrice) { + return { + amount: matchingPrice.amount || 0, + currencyCode: matchingPrice.currency_code?.toUpperCase() || defaultCurrency, + }; + } + } + + return { amount: 0, currencyCode: defaultCurrency }; +}; + +// Extract keySpecs from product/variant metadata +// Medusa stores metadata values as strings, so we need to parse JSON +// Expected format: key="keySpecs", value='[{ "value": "1200W", "icon": "Zap" }, ...]' +const mapKeySpecsFromMetadata = (variant: AnyProductVariant): Products.Model.KeySpecItem[] | undefined => { + const variantMetadata = variant.metadata as Record | null; + const productMetadata = variant.product?.metadata as Record | null; + + // Check variant metadata first, then product metadata + const rawKeySpecs = variantMetadata?.keySpecs ?? productMetadata?.keySpecs; + + if (!rawKeySpecs) { + return undefined; + } + + // Parse if it's a string (Medusa stores metadata values as strings) + let keySpecs: { value?: string; icon?: string }[]; + if (typeof rawKeySpecs === 'string') { + try { + keySpecs = JSON.parse(rawKeySpecs); + } catch { + return undefined; + } + } else if (Array.isArray(rawKeySpecs)) { + keySpecs = rawKeySpecs; + } else { + return undefined; + } + + if (!Array.isArray(keySpecs) || keySpecs.length === 0) { + return undefined; + } + + return keySpecs.map((spec) => ({ + value: spec.value, + icon: spec.icon, + })); +}; + +// Map variant and product attributes to detailedSpecs dynamically +// Variant attributes take precedence over product attributes +const mapVariantToDetailedSpecs = (variant: AnyProductVariant): Products.Model.DetailedSpec[] => { + const specs: Products.Model.DetailedSpec[] = []; + const product = variant.product; + + for (const field of VARIANT_SPEC_FIELDS) { + // Check variant first, then fall back to product + const variantValue = variant[field as keyof AnyProductVariant]; + const productValue = product?.[field as keyof AnyProduct]; + const value = variantValue ?? productValue; + + if (value != null && value !== '') { + specs.push({ + label: formatLabel(field), + value: String(value), + }); + } + } + + return specs; +}; + +export const mapProduct = (productVariant: AnyProductVariant, defaultCurrency: string): Products.Model.Product => { + if (!productVariant) { + throw new NotFoundException('Product variant is undefined'); + } + + const product = productVariant?.product; + const price = getVariantPrice(productVariant, defaultCurrency); + + // Build detailedSpecs from variant attributes dynamically + const detailedSpecs = mapVariantToDetailedSpecs(productVariant); + return { - id: productVariant?.product?.id || '', + id: product?.id || '', sku: productVariant?.sku || '', - name: productVariant?.product?.title || '', - description: productVariant?.product?.description || '', - shortDescription: (productVariant?.product?.subtitle as string) || '', + name: product?.title || '', + description: product?.description || '', + shortDescription: (product?.subtitle as string) || '', variantId: productVariant.id, - image: productVariant?.product?.thumbnail + image: product?.thumbnail ? { - url: productVariant?.product?.thumbnail, - alt: productVariant?.product?.title, + url: product.thumbnail, + alt: product.title, } : undefined, + images: product?.images?.map((img) => ({ + url: img.url, + alt: product?.title || '', + })), price: { - value: price?.amount || 0, - currency: - (price?.currency_code.toUpperCase() as Models.Price.Currency) || - (defaultCurrency as Models.Price.Currency), + value: price.amount, + currency: price.currencyCode as Models.Price.Currency, }, - link: '', - type: mapProductType(productVariant?.product?.type || undefined), - category: productVariant?.product?.categories?.[0]?.name || '', - tags: [], + link: `/products/${product?.id || ''}`, + type: mapProductType(product?.type || undefined), + category: product?.categories?.[0]?.name || '', + tags: + product?.tags?.map((tag) => ({ + label: tag.value || '', + variant: 'default', + })) || [], + detailedSpecs: detailedSpecs.length > 0 ? detailedSpecs : undefined, + keySpecs: mapKeySpecsFromMetadata(productVariant), }; }; export const mapProducts = ( - data: HttpTypes.AdminProductListResponse, + data: HttpTypes.StoreProductListResponse, defaultCurrency: string, + categoryFilter?: string, ): Products.Model.Products => { + let products = data.products; + + // Filter by category name if provided + if (categoryFilter) { + products = products.filter((product) => product.categories?.some((cat) => cat.name === categoryFilter)); + } + return { - data: data.products.map((product) => { + data: products.map((product) => { + const firstVariant = product.variants?.[0]; + const price = firstVariant + ? getVariantPrice(firstVariant, defaultCurrency) + : { amount: 0, currencyCode: defaultCurrency }; + return { id: product.id, - sku: '', + sku: firstVariant?.sku || '', name: product.title, description: product?.description || '', + variantId: firstVariant?.id || '', image: product?.thumbnail ? { url: product.thumbnail, alt: product.title, } : undefined, + images: product.images?.map((img) => ({ + url: img.url, + alt: product.title, + })), price: { - value: 0, - currency: defaultCurrency as Models.Price.Currency, + value: price.amount, + currency: price.currencyCode as Models.Price.Currency, }, - link: '', + link: `/products/${product.id}`, type: mapProductType(product?.type || undefined), category: product.categories?.[0]?.name || '', - tags: [], + tags: + product.tags?.map((tag) => ({ + label: tag.value || '', + variant: 'default', + })) || [], }; }), - total: data.count, + total: categoryFilter ? products.length : data.count, }; }; export const mapRelatedProducts = (data: RelatedProductsResponse, defaultCurrency: string): Products.Model.Products => { return { - data: data.productReferences.map((product) => { + data: data.productReferences.map((ref) => { + const targetProduct = ref.targetProduct; + const product = targetProduct.product; + const price = targetProduct.prices?.find((p) => p.currency_code?.toUpperCase() === defaultCurrency); + return { - id: product.targetProduct.id, - sku: product.targetProduct.sku || '', - name: product.targetProduct.title, - description: product.targetProduct.product?.description || '', - shortDescription: product.targetProduct.product?.description || undefined, + id: targetProduct.id, + sku: targetProduct.sku || '', + name: targetProduct.title, + description: product?.description || '', + shortDescription: product?.subtitle || product?.description || undefined, image: { - url: product.targetProduct.product?.thumbnail || '', - alt: product.targetProduct.title, + url: product?.thumbnail || '', + alt: targetProduct.title, }, + images: product?.images?.map((img) => ({ + url: img.url, + alt: product?.title || '', + })), price: { - value: 0, - currency: defaultCurrency as Models.Price.Currency, + value: price?.amount || 0, + currency: + (price?.currency_code?.toUpperCase() as Models.Price.Currency) || + (defaultCurrency as Models.Price.Currency), }, - link: '', - type: mapProductType(product.targetProduct.product?.type || undefined), - category: product.targetProduct.product?.categories?.[0]?.name || '', - tags: [], + link: `/products/${product?.id || ''}`, + type: mapProductType(product?.type || undefined), + category: product?.categories?.[0]?.name || '', + tags: + product?.tags?.map((tag) => ({ + label: tag.value || '', + variant: 'default', + })) || [], }; }), total: data.count, @@ -120,7 +298,9 @@ export const mapFeaturedServices = ( }; }; -export const mapProductType = (type?: HttpTypes.AdminProductType): Products.Model.ProductType => { +export const mapProductType = ( + type?: HttpTypes.StoreProductType | HttpTypes.AdminProductType, +): Products.Model.ProductType => { if (!type) { return 'PHYSICAL'; } diff --git a/packages/integrations/medusajs/src/modules/products/products.service.ts b/packages/integrations/medusajs/src/modules/products/products.service.ts index 7ebbfb1bd..323635d9a 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.ts @@ -3,7 +3,7 @@ import { HttpTypes } from '@medusajs/types'; import { HttpService } from '@nestjs/axios'; import { Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Observable, catchError, map } from 'rxjs'; +import { Observable, catchError, from, map } from 'rxjs'; import { LoggerService } from '@o2s/utils.logger'; @@ -16,11 +16,24 @@ 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 listing and retrieval (public endpoints, no auth required). + * Uses Admin API only for related products (custom endpoint without Store API equivalent). + * + * Note: Store API pricing requires a pricing context (region_id or country_code) to + * populate variant `calculated_price`. Without it, prices may default to 0. + */ @Injectable() export class ProductsService extends Products.Service { private readonly sdk: Medusa; private readonly defaultCurrency: string; + private readonly productListFields = '*variants,*variants.calculated_price,*categories,*tags,*images'; + private readonly productDetailFields = + '+weight,+height,+width,+length,+material,+origin_country,+hs_code,+mid_code,+metadata,+product.metadata,product.*,*product.images,*product.tags,*calculated_price'; + constructor( private readonly config: ConfigService, protected httpClient: HttpService, @@ -36,46 +49,63 @@ export class ProductsService extends Products.Service { } } + /** + * Lists products using Medusa Store API. + * Store API only returns published products (no status filter needed). + */ getProductList(query: Products.Request.GetProductListQuery): Observable { - return this.httpClient - .get(`${this.medusaJsService.getBaseUrl()}/admin/products`, { - headers: this.medusaJsService.getMedusaAdminApiHeaders(), - params: { - limit: query.limit, - offset: query.offset, - }, - }) - .pipe( - map((response) => { - return mapProducts(response.data, this.defaultCurrency); - }), - catchError((error) => { - return handleHttpError(error); - }), - ); + const params: HttpTypes.StoreProductListParams = { + limit: query.limit, + offset: query.offset, + fields: this.productListFields, + }; + + return from(this.sdk.store.product.list(params)).pipe( + map((response: HttpTypes.StoreProductListResponse) => + mapProducts(response, this.defaultCurrency, query.category), + ), + catchError((error) => { + return handleHttpError(error); + }), + ); } + /** + * Retrieves a product by ID using Medusa Store API. + * If variantId is not provided, uses the first variant. + */ getProduct(params: Products.Request.GetProductParams): Observable { - return this.httpClient - .get( - `${this.medusaJsService.getBaseUrl()}/admin/products/${params.id}/variants/${params.variantId}`, - { - headers: this.medusaJsService.getMedusaAdminApiHeaders(), - params: { - fields: 'product.*', - }, - }, - ) - .pipe( - map((response) => { - return mapProduct(response.data.variant, this.defaultCurrency); - }), - catchError((error) => { - return handleHttpError(error); - }), - ); + return from(this.sdk.store.product.retrieve(params.id, { fields: this.productDetailFields })).pipe( + map((response: HttpTypes.StoreProductResponse) => { + const product = response.product; + if (!product?.variants?.length) { + throw new Error(`No variants found for product ${params.id}`); + } + + // Find the requested variant, or use the first one + const variants = product.variants as HttpTypes.StoreProductVariant[]; + const variant = params.variantId ? variants.find((v) => v.id === params.variantId) : variants[0]; + + if (!variant) { + throw new Error(`Variant ${params.variantId} not found for product ${params.id}`); + } + + // Ensure the variant has a reference to the product for mapping + const variantWithProduct = { ...variant, product }; + + return mapProduct(variantWithProduct, this.defaultCurrency); + }), + catchError((error) => { + return handleHttpError(error); + }), + ); } + /** + * Retrieves related products using Admin API (custom endpoint). + * No Store API equivalent exists for this custom endpoint. + * Uses Admin API key for authentication. + */ getRelatedProductList(params: Products.Request.GetRelatedProductListParams): Observable { return this.httpClient .get( @@ -84,6 +114,7 @@ export class ProductsService extends Products.Service { headers: this.medusaJsService.getMedusaAdminApiHeaders(), params: { referenceType: params.type, + limit: params.limit, }, }, ) diff --git a/packages/integrations/medusajs/src/modules/products/response.types.ts b/packages/integrations/medusajs/src/modules/products/response.types.ts index 32d70e522..4f0b26308 100644 --- a/packages/integrations/medusajs/src/modules/products/response.types.ts +++ b/packages/integrations/medusajs/src/modules/products/response.types.ts @@ -15,6 +15,7 @@ export type TargetProduct = { ean: string; product_id: string; product: HttpTypes.AdminProduct; + prices?: HttpTypes.AdminProductVariant['prices']; }; export type RelatedProductsResponse = { diff --git a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts index edf04d698..d06fa8678 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts @@ -88,6 +88,8 @@ export const mapService = ( const mapAddress = (address: AddressDTO): Models.Address.Address | undefined => { if (!address) return undefined; return { + firstName: (address as unknown as { first_name?: string }).first_name, + lastName: (address as unknown as { last_name?: string }).last_name, country: address.country_code || '', district: address.province || '', region: address.province || '', diff --git a/packages/integrations/mocked/prisma/seed.ts b/packages/integrations/mocked/prisma/seed.ts index 027fb8367..177597239 100644 --- a/packages/integrations/mocked/prisma/seed.ts +++ b/packages/integrations/mocked/prisma/seed.ts @@ -15,7 +15,7 @@ async function main() { name: 'Jane Doe', email: 'jane@example.com', password: await hash('admin', 10), - defaultCustomerId: 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', // Acme Corp - full admin permissions + defaultCustomerId: 'cus_01KH3J08TY40PYGVEG3A04CP8R', // Acme Corp - full admin permissions }, { id: 'user-100', diff --git a/packages/integrations/mocked/src/integration.ts b/packages/integrations/mocked/src/integration.ts index 711e1a539..7a35f9032 100644 --- a/packages/integrations/mocked/src/integration.ts +++ b/packages/integrations/mocked/src/integration.ts @@ -1,15 +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'; @@ -79,6 +82,20 @@ export const Config: Partial = { service: CartsService, imports: [Auth.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/carts/carts.mapper.ts b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts index 33b7ec3c0..bdf094b73 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -1,4 +1,4 @@ -import { Carts, Products } from '@o2s/framework/modules'; +import { Carts, Models, Products } from '@o2s/framework/modules'; // Product data for generating cart items const PRODUCT_DATA = [ @@ -87,6 +87,19 @@ const PAYMENT_METHODS: Carts.Model.PaymentMethod[] = [ }, ]; +// Read payment method stored in metadata by setupPayment +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, + type: (stored.type as Carts.Model.PaymentMethodType) ?? 'OTHER', + }; +}; + // Promotions const PROMOTIONS: Carts.Model.Promotion[] = [ { @@ -282,12 +295,12 @@ const generateCart = ( // Generate mocked carts const MOCKED_CARTS: Carts.Model.Cart[] = [ // Active cart for authenticated customer - generateCart('CART-001', 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', 'ACTIVE'), + generateCart('CART-001', 'cus_01KH3J08TY40PYGVEG3A04CP8R', 'ACTIVE'), // Saved carts for authenticated customer - generateCart('CART-002', 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', 'SAVED', 'Wishlist'), - generateCart('CART-003', 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', 'SAVED', 'Work Equipment'), + generateCart('CART-002', 'cus_01KH3J08TY40PYGVEG3A04CP8R', 'SAVED', 'Wishlist'), + generateCart('CART-003', 'cus_01KH3J08TY40PYGVEG3A04CP8R', 'SAVED', 'Work Equipment'), // Abandoned cart - generateCart('CART-004', 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', 'ABANDONED'), + generateCart('CART-004', 'cus_01KH3J08TY40PYGVEG3A04CP8R', 'ABANDONED'), // Guest cart (no customer ID) generateCart('CART-005', undefined, 'ACTIVE'), ]; @@ -380,13 +393,32 @@ export const updateCart = ( 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; + + // Resolve payment method: from paymentMethodId, from metadata paymentProviderId, or keep existing + const paymentMethod = data.paymentMethodId + ? PAYMENT_METHODS.find((pm) => pm.id === data.paymentMethodId) + : (mapPaymentMethodFromMetadata(mergedMetadata) ?? cart.paymentMethod); + const updatedCart: Carts.Model.Cart = { ...cart, name: data.name ?? cart.name, type: data.type ?? cart.type, regionId: data.regionId ?? cart.regionId, notes: data.notes ?? cart.notes, - metadata: data.metadata ?? cart.metadata, + metadata: mergedMetadata, + shippingAddress: shippingAddressFromMetadata ?? cart.shippingAddress, + billingAddress: billingAddressFromMetadata ?? cart.billingAddress, + paymentMethod: paymentMethod ?? cart.paymentMethod, updatedAt: formatDate(new Date()), }; diff --git a/packages/integrations/mocked/src/modules/carts/carts.service.ts b/packages/integrations/mocked/src/modules/carts/carts.service.ts index 2d0d43dbd..071a9cb1d 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.service.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { Observable, of } from 'rxjs'; import { Auth, Carts } from '@o2s/framework/modules'; @@ -310,4 +310,120 @@ export class CartsService implements Carts.Service { 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`); + } + + // Verify authorization for customer carts + if (cart.customerId && authorization) { + 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) { + throw new NotFoundException('Cart not found'); + } + + // Verify authorization for customer carts + if (existingCart.customerId && authorization) { + const customerId = this.authService.getCustomerId(authorization); + if (existingCart.customerId !== customerId) { + throw new UnauthorizedException('Unauthorized to update this cart'); + } + } + + // Build update data + const updateData: Carts.Request.UpdateCartBody = { + notes: data.notes, + metadata: { + ...existingCart.metadata, + ...(data.guestEmail ? { guestEmail: data.guestEmail } : {}), + }, + }; + + // Set addresses if provided + if (data.shippingAddressId) { + updateData.shippingAddressId = data.shippingAddressId; + } + if (data.shippingAddress) { + // Store in metadata for mocked implementation + updateData.metadata = { + ...updateData.metadata, + shippingAddress: data.shippingAddress, + }; + } + if (data.billingAddressId) { + updateData.billingAddressId = data.billingAddressId; + } + if (data.billingAddress) { + // Store in metadata for mocked implementation + updateData.metadata = { + ...updateData.metadata, + billingAddress: data.billingAddress, + }; + } + + const cart = updateCart({ id: params.cartId }, updateData); + if (!cart) { + throw new NotFoundException('Cart not found'); + } + + return of(cart).pipe(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'); + } + + // Verify authorization for customer carts + if (existingCart.customerId && authorization) { + 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 cart = updateCart({ id: params.cartId }, { shippingMethodId: data.shippingOptionId }); + if (!cart) { + throw new NotFoundException('Cart not found'); + } + + return of(cart).pipe(responseDelay()); + } } 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..f04f1fbb8 --- /dev/null +++ b/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts @@ -0,0 +1,102 @@ +import { BadRequestException } from '@nestjs/common'; + +import { Carts, Checkout, Orders, Payments } from '@o2s/framework/modules'; + +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, + guestEmail: cart.metadata?.guestEmail as string | undefined, + }; +} + +export function mapPlaceOrderResponse( + order: Orders.Model.Order, + paymentSession?: Payments.Model.PaymentSession, +): Checkout.Model.PlaceOrderResponse { + return { + order, + paymentRedirectUrl: paymentSession?.redirectUrl, + }; +} + +const MOCK_SHIPPING_OPTIONS: Orders.Model.ShippingMethod[] = [ + { + 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' }, + }, +]; + +export function mapShippingOptions(): Checkout.Model.ShippingOptions { + return { + data: MOCK_SHIPPING_OPTIONS, + total: MOCK_SHIPPING_OPTIONS.length, + }; +} 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..533353caa --- /dev/null +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -0,0 +1,244 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Observable, of, throwError } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +import { Auth, Carts, Checkout, Customers, 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 authService: Auth.Service, + private readonly cartsService: Carts.Service, + private readonly customersService: Customers.Service, + private readonly paymentsService: Payments.Service, + ) {} + + setupAddresses( + params: Checkout.Request.SetupAddressesParams, + data: Checkout.Request.SetupAddressesBody, + 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 checkout')); + } + + // Delegate to cart service + return this.cartsService.updateCartAddresses({ cartId: params.cartId }, data, authorization); + }), + responseDelay(), + ); + } + + setupShippingMethod( + params: Checkout.Request.SetupShippingMethodParams, + data: Checkout.Request.SetupShippingMethodBody, + 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(), + ); + } + + setupPayment( + params: Checkout.Request.SetupPaymentParams, + data: Checkout.Request.SetupPaymentBody, + 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: 'https://example.com/checkout/return', + cancelUrl: 'https://example.com/checkout/cancel', + 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))); + } + + return of(mapCheckoutSummary(cart)); + }), + 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 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')); + } + + const paymentSessionId = cart.metadata?.paymentSessionId as string | undefined; + if (!paymentSessionId) { + return throwError(() => new BadRequestException('Payment session is required')); + } + + // Get guest email (from cart metadata or request body) + const guestEmail = data?.guestEmail || (cart.metadata?.guestEmail as string | undefined); + + // Create order from cart + const order = mapOrderFromCart(cart, guestEmail); + 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()).pipe(responseDelay()); + } + + completeCheckout( + params: Checkout.Request.CompleteCheckoutParams, + data: Checkout.Request.CompleteCheckoutBody, + authorization: string | undefined, + ): Observable { + // Setup addresses first + return this.setupAddresses( + { cartId: params.cartId }, + { + shippingAddressId: data.shippingAddressId, + shippingAddress: data.shippingAddress, + billingAddressId: data.billingAddressId, + billingAddress: data.billingAddress, + notes: data.notes, + guestEmail: data.guestEmail, + }, + authorization, + ).pipe( + // Setup shipping method if provided + switchMap(() => + data.shippingMethodId + ? this.setupShippingMethod( + { cartId: params.cartId }, + { shippingOptionId: data.shippingMethodId }, + authorization, + ) + : of(null), + ), + switchMap(() => + // Setup payment + this.setupPayment( + { cartId: params.cartId }, + { + providerId: data.paymentProviderId, + metadata: data.metadata, + }, + authorization, + ), + ), + switchMap(() => + // Place order + this.placeOrder( + { cartId: params.cartId }, + { + guestEmail: data.guestEmail, + }, + 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..7a75c4eaa --- /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()}`; + + 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 36c66eb55..994250298 100644 --- a/packages/integrations/mocked/src/modules/index.ts +++ b/packages/integrations/mocked/src/modules/index.ts @@ -12,4 +12,7 @@ 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 46111e62b..1b95596f7 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 = [ @@ -140,7 +142,7 @@ const DOCUMENT_DATA: Orders.Model.Document[] = [ ]; // Customer IDs -const CUSTOMER_IDS = ['cus_01KGSR4NSX1S7Y48E6MVWPPVDP']; +const CUSTOMER_IDS = ['cus_01KH3J08TY40PYGVEG3A04CP8R']; // Shipping methods const SHIPPING_METHODS = [ @@ -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, guestEmail?: 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.productId, + 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: guestEmail, + }; +} diff --git a/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts b/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts index d6a8c86f2..b6a3d67d8 100644 --- a/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts +++ b/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts @@ -66,7 +66,7 @@ const MOCK_ORGANIZATION_1: Organizations.Model.Organization = { }, isActive: true, children: [MOCK_ORGANIZATION_2, MOCK_ORGANIZATION_3], - customers: [mapCustomer('cus_01KGSR4NSX1S7Y48E6MVWPPVDP')!], + customers: [mapCustomer('cus_01KH3J08TY40PYGVEG3A04CP8R')!], }; const MOCK_ORGANIZATIONS = [MOCK_ORGANIZATION_1, MOCK_ORGANIZATION_2, MOCK_ORGANIZATION_3]; 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..6a0d5ee8a --- /dev/null +++ b/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts @@ -0,0 +1,38 @@ +import { Payments } from '@o2s/framework/modules'; + +const MOCK_PROVIDERS: Payments.Model.PaymentProvider[] = [ + { + 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: {}, + }, +]; + +export function getMockProviders(): Payments.Model.PaymentProvider[] { + return MOCK_PROVIDERS; +} + +export function getMockProviderById(id: string): Payments.Model.PaymentProvider | undefined { + return MOCK_PROVIDERS.find((provider) => provider.id === id); +} 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..01f18b382 --- /dev/null +++ b/packages/integrations/mocked/src/modules/payments/payments.mapper.ts @@ -0,0 +1,56 @@ +import { Payments } from '@o2s/framework/modules'; + +export function mapPaymentProviders( + providers: Payments.Model.PaymentProvider[], + limit = 10, + offset = 0, +): Payments.Model.PaymentProviders { + const total = providers.length; + const paginatedProviders = providers.slice(offset, offset + limit); + + return { + data: paginatedProviders, + 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..f859097b9 --- /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(); + return of(mapPaymentProviders(providers)).pipe(responseDelay()); + } + + createSession( + data: Payments.Request.CreateSessionBody, + _authorization: string | undefined, + ): Observable { + const provider = getMockProviderById(data.providerId); + + 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/resources/resources.mapper.ts b/packages/integrations/mocked/src/modules/resources/resources.mapper.ts index ab4f70d42..8b2eb5d86 100644 --- a/packages/integrations/mocked/src/modules/resources/resources.mapper.ts +++ b/packages/integrations/mocked/src/modules/resources/resources.mapper.ts @@ -395,7 +395,7 @@ export const mapAssets = ( let assets: Resources.Model.Asset[] = []; switch (customerId) { - case 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP': + case 'cus_01KH3J08TY40PYGVEG3A04CP8R': assets = MOCK_ASSETS_FOR_CUSTOMER_1; break; case 'cust-002': @@ -474,7 +474,7 @@ export const mapServices = ( let services = MOCK_SERVICES_DEFAULT; switch (customerId) { - case 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP': + case 'cus_01KH3J08TY40PYGVEG3A04CP8R': services = MOCK_SERVICES_FOR_CUSTOMER_1; break; case 'cust-002': diff --git a/packages/integrations/mocked/src/modules/users/customers.mapper.ts b/packages/integrations/mocked/src/modules/users/customers.mapper.ts index 611b27d30..9c6d01f95 100644 --- a/packages/integrations/mocked/src/modules/users/customers.mapper.ts +++ b/packages/integrations/mocked/src/modules/users/customers.mapper.ts @@ -32,7 +32,7 @@ const PROSPECT_PERMISSIONS: Auth.Model.Permissions = { }; const MOCK_CUSTOMER_1: Models.Customer.Customer = { - id: 'cus_01KGSR4NSX1S7Y48E6MVWPPVDP', + id: 'cus_01KH3J08TY40PYGVEG3A04CP8R', name: 'Acme Corporation', clientType: 'B2B', address: { diff --git a/packages/integrations/mocked/src/modules/users/users.mapper.ts b/packages/integrations/mocked/src/modules/users/users.mapper.ts index 8da1e853d..5df20fb61 100644 --- a/packages/integrations/mocked/src/modules/users/users.mapper.ts +++ b/packages/integrations/mocked/src/modules/users/users.mapper.ts @@ -23,7 +23,7 @@ const MOCK_USER_2: Users.Model.User = { firstName: 'Jane', lastName: 'Doe', customers: [ - mapCustomer('cus_01KGSR4NSX1S7Y48E6MVWPPVDP')!, // Acme Corp - admin permissions (full access) + mapCustomer('cus_01KH3J08TY40PYGVEG3A04CP8R')!, // Acme Corp - admin permissions (full access) mapCustomer('cust-002')!, // Tech Solutions - user permissions (view + pay) ], }; From efe4d2948d36326f27084be88c0816853deb8a41 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Thu, 12 Feb 2026 14:42:44 +0100 Subject: [PATCH 03/27] feat: refactor checkout and cart modules with updated field mappings - Renamed methods, parameters, and models for clarity and consistency (e.g., `setupAddresses` to `setAddresses`). - Centralized support for guest email handling using `email` field across carts, orders, and checkout processes. - Removed redundant calls and optimized order completion workflow. --- .../src/modules/carts/carts.model.ts | 1 + .../src/modules/carts/carts.request.ts | 3 +- .../modules/checkout/checkout.controller.ts | 24 +++--- .../src/modules/checkout/checkout.model.ts | 2 +- .../src/modules/checkout/checkout.request.ts | 18 ++-- .../src/modules/checkout/checkout.service.ts | 24 +++--- .../src/modules/carts/carts.mapper.ts | 3 +- .../src/modules/carts/carts.service.ts | 40 ++++++--- .../src/modules/checkout/checkout.mapper.ts | 2 +- .../src/modules/checkout/checkout.service.ts | 84 ++++++++----------- .../src/modules/customers/customers.mapper.ts | 9 +- .../modules/customers/customers.service.ts | 2 +- .../src/modules/orders/orders.service.spec.ts | 2 - .../src/modules/payments/payments.mapper.ts | 2 +- .../src/modules/payments/payments.service.ts | 23 ++--- .../src/modules/resources/resources.mapper.ts | 9 +- .../mocked/src/modules/auth/auth.service.ts | 2 +- .../mocked/src/modules/carts/carts.mapper.ts | 3 +- .../mocked/src/modules/carts/carts.service.ts | 2 +- .../src/modules/checkout/checkout.mapper.ts | 2 +- .../src/modules/checkout/checkout.service.ts | 40 ++++----- .../src/modules/orders/orders.mapper.ts | 4 +- 22 files changed, 148 insertions(+), 153 deletions(-) diff --git a/packages/framework/src/modules/carts/carts.model.ts b/packages/framework/src/modules/carts/carts.model.ts index 3d02b7b69..0f9814243 100644 --- a/packages/framework/src/modules/carts/carts.model.ts +++ b/packages/framework/src/modules/carts/carts.model.ts @@ -69,6 +69,7 @@ export class Cart { promotions?: Promotion[]; metadata?: Record; notes?: string; + email?: string; // For guest checkout paymentSessionId?: string; // Reference to active payment session } diff --git a/packages/framework/src/modules/carts/carts.request.ts b/packages/framework/src/modules/carts/carts.request.ts index 7204f01d1..4215d5d92 100644 --- a/packages/framework/src/modules/carts/carts.request.ts +++ b/packages/framework/src/modules/carts/carts.request.ts @@ -32,6 +32,7 @@ export class UpdateCartBody { name?: string; type?: CartType; regionId?: string; + email?: string; // For guest checkout (passed directly to cart, not metadata) shippingAddressId?: string; billingAddressId?: string; shippingMethodId?: string; @@ -101,7 +102,7 @@ export class UpdateCartAddressesBody { billingAddressId?: string; // Use saved address (authenticated users only) billingAddress?: Address.Address; // Or provide new address notes?: string; - guestEmail?: string; // For guest checkout + email?: string; // For guest checkout } // Shipping method operations diff --git a/packages/framework/src/modules/checkout/checkout.controller.ts b/packages/framework/src/modules/checkout/checkout.controller.ts index 318ecbb7c..b523ff942 100644 --- a/packages/framework/src/modules/checkout/checkout.controller.ts +++ b/packages/framework/src/modules/checkout/checkout.controller.ts @@ -12,30 +12,30 @@ export class CheckoutController { constructor(protected readonly checkoutService: CheckoutService) {} @Post(':cartId/addresses') - setupAddresses( - @Param() params: Request.SetupAddressesParams, - @Body() body: Request.SetupAddressesBody, + setAddresses( + @Param() params: Request.SetAddressesParams, + @Body() body: Request.SetAddressesBody, @Headers() headers: AppHeaders, ) { - return this.checkoutService.setupAddresses(params, body, headers.authorization); + return this.checkoutService.setAddresses(params, body, headers.authorization); } @Post(':cartId/shipping-method') - setupShippingMethod( - @Param() params: Request.SetupShippingMethodParams, - @Body() body: Request.SetupShippingMethodBody, + setShippingMethod( + @Param() params: Request.SetShippingMethodParams, + @Body() body: Request.SetShippingMethodBody, @Headers() headers: AppHeaders, ) { - return this.checkoutService.setupShippingMethod(params, body, headers.authorization); + return this.checkoutService.setShippingMethod(params, body, headers.authorization); } @Post(':cartId/payment') - setupPayment( - @Param() params: Request.SetupPaymentParams, - @Body() body: Request.SetupPaymentBody, + setPayment( + @Param() params: Request.SetPaymentParams, + @Body() body: Request.SetPaymentBody, @Headers() headers: AppHeaders, ) { - return this.checkoutService.setupPayment(params, body, headers.authorization); + return this.checkoutService.setPayment(params, body, headers.authorization); } @Get(':cartId/shipping-options') diff --git a/packages/framework/src/modules/checkout/checkout.model.ts b/packages/framework/src/modules/checkout/checkout.model.ts index 631d9a77b..ecf1056d3 100644 --- a/packages/framework/src/modules/checkout/checkout.model.ts +++ b/packages/framework/src/modules/checkout/checkout.model.ts @@ -17,7 +17,7 @@ export class CheckoutSummary { total: Price.Price; }; notes?: string; - guestEmail?: string; // Required for guest checkout + email?: string; // For guest checkout } export class ShippingOptions { diff --git a/packages/framework/src/modules/checkout/checkout.request.ts b/packages/framework/src/modules/checkout/checkout.request.ts index edb769933..afca01f86 100644 --- a/packages/framework/src/modules/checkout/checkout.request.ts +++ b/packages/framework/src/modules/checkout/checkout.request.ts @@ -1,31 +1,31 @@ import { Address } from '@/utils/models'; -export class SetupAddressesParams { +export class SetAddressesParams { cartId!: string; } -export class SetupAddressesBody { +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; - guestEmail?: string; // For guest checkout + email?: string; // For guest checkout } -export class SetupShippingMethodParams { +export class SetShippingMethodParams { cartId!: string; } -export class SetupShippingMethodBody { +export class SetShippingMethodBody { shippingOptionId!: string; // Shipping option ID from getShippingOptions() } -export class SetupPaymentParams { +export class SetPaymentParams { cartId!: string; } -export class SetupPaymentBody { +export class SetPaymentBody { providerId!: string; metadata?: Record; } @@ -45,7 +45,7 @@ export class PlaceOrderParams { export class PlaceOrderBody { // Optional - can be empty if all data already in cart // Allows frontend to confirm before placing - guestEmail?: string; // Required for guest checkout if not provided in shipping setup + email?: string; // Required for guest checkout if not provided in shipping setup } export class CompleteCheckoutParams { @@ -60,6 +60,6 @@ export class CompleteCheckoutBody { shippingMethodId?: string; paymentProviderId!: string; notes?: string; - guestEmail?: string; // Required for guest checkout (for order confirmation) + 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 index 73124215f..f03951949 100644 --- a/packages/framework/src/modules/checkout/checkout.service.ts +++ b/packages/framework/src/modules/checkout/checkout.service.ts @@ -8,24 +8,24 @@ import * as Checkout from './'; export abstract class CheckoutService { protected constructor(..._services: unknown[]) {} - // Setup addresses (shipping and/or billing) - abstract setupAddresses( - params: Checkout.Request.SetupAddressesParams, - data: Checkout.Request.SetupAddressesBody, + // Set addresses (shipping and/or billing) + abstract setAddresses( + params: Checkout.Request.SetAddressesParams, + data: Checkout.Request.SetAddressesBody, authorization?: string, ): Observable; - // Setup shipping method - abstract setupShippingMethod( - params: Checkout.Request.SetupShippingMethodParams, - data: Checkout.Request.SetupShippingMethodBody, + // Set shipping method + abstract setShippingMethod( + params: Checkout.Request.SetShippingMethodParams, + data: Checkout.Request.SetShippingMethodBody, authorization?: string, ): Observable; - // Setup payment (independent action, can be called before or after shipping) - abstract setupPayment( - params: Checkout.Request.SetupPaymentParams, - data: Checkout.Request.SetupPaymentBody, + // Set payment (independent action, can be called before or after shipping) + abstract setPayment( + params: Checkout.Request.SetPaymentParams, + data: Checkout.Request.SetPaymentBody, authorization?: string, ): Observable; diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index e7c19c6a8..033591b33 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -18,8 +18,6 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca } const currency = cart.currency_code as Models.Price.Currency; - console.log('cart.customer_id', cart.customer_id); - return { id: cart.id, customerId: cart.customer_id ?? undefined, @@ -46,6 +44,7 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca promotions: mapPromotions(cart), metadata: (cart.metadata as Record) ?? {}, notes: undefined, + email: cart.email ?? undefined, }; }; diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 39bec1aaa..917eff2ae 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -101,13 +101,29 @@ export class CartsService extends Carts.Service { 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, - { - region_id: data.regionId, - metadata: data.metadata, - }, + cartUpdate, {}, this.medusaJsService.getStoreApiHeaders(authorization), ), @@ -414,14 +430,19 @@ export class CartsService extends Carts.Service { ); } - // Build metadata - const metadata = this.buildCartMetadata(data.notes, data.guestEmail, cart.metadata); + // 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; @@ -520,21 +541,18 @@ export class CartsService extends Carts.Service { } /** - * Builds cart metadata immutably by merging optional notes and guestEmail + * 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 | undefined, - guestEmail: string | undefined, existingMetadata?: Record, ): Record { const metadata: Record = { ...(existingMetadata || {}) }; if (notes !== undefined) { metadata.notes = notes; } - if (guestEmail) { - metadata.guestEmail = guestEmail; - } return metadata; } } diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts index ff622bc99..8698a0bf0 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts @@ -57,7 +57,7 @@ export function mapCheckoutSummary( total: cart.total, }, notes: cart.notes, - guestEmail: cart.metadata?.guestEmail as string | undefined, + email: cart.email, }; } diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts index 4a075620c..2ae5eafc7 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts @@ -6,10 +6,11 @@ import { Observable, catchError, forkJoin, from, map, of, switchMap, throwError import { LoggerService } from '@o2s/utils.logger'; -import { Auth, Carts, Checkout, Customers, Orders, Payments } from '@o2s/framework/modules'; +import { Auth, Carts, Checkout, Customers, Payments } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; +import { mapOrder } from '../orders/orders.mapper'; import { handleHttpError } from '../utils/handle-http-error'; import { mapCheckoutSummary, mapPlaceOrderResponse, mapShippingOptions } from './checkout.mapper'; @@ -27,16 +28,15 @@ export class CheckoutService extends Checkout.Service { private readonly cartsService: Carts.Service, private readonly customersService: Customers.Service, private readonly paymentsService: Payments.Service, - private readonly ordersService: Orders.Service, ) { super(); this.sdk = this.medusaJsService.getSdk(); this.defaultCurrency = this.config.get('DEFAULT_CURRENCY') || ''; } - setupAddresses( - params: Checkout.Request.SetupAddressesParams, - data: Checkout.Request.SetupAddressesBody, + setAddresses( + params: Checkout.Request.SetAddressesParams, + data: Checkout.Request.SetAddressesBody, authorization: string | undefined, ): Observable { // Validate cart exists and has items @@ -57,9 +57,9 @@ export class CheckoutService extends Checkout.Service { ); } - setupShippingMethod( - params: Checkout.Request.SetupShippingMethodParams, - data: Checkout.Request.SetupShippingMethodBody, + setShippingMethod( + params: Checkout.Request.SetShippingMethodParams, + data: Checkout.Request.SetShippingMethodBody, authorization: string | undefined, ): Observable { // Validate cart exists and has items @@ -86,9 +86,9 @@ export class CheckoutService extends Checkout.Service { ); } - setupPayment( - params: Checkout.Request.SetupPaymentParams, - data: Checkout.Request.SetupPaymentBody, + setPayment( + params: Checkout.Request.SetPaymentParams, + data: Checkout.Request.SetPaymentBody, authorization: string | undefined, ): Observable { return this.paymentsService @@ -179,28 +179,22 @@ export class CheckoutService extends Checkout.Service { return throwError(() => new BadRequestException('Shipping method is required')); } - // Store guest email in cart metadata if provided - const guestEmail = data?.guestEmail || (cart.metadata?.guestEmail as string | undefined); - if (guestEmail) { + // 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 }, - { metadata: { ...cart.metadata, guestEmail } }, - authorization, - ) - .pipe( - switchMap(() => this.completeCartAndCreateOrder(params.cartId, guestEmail, authorization)), - ); + .updateCart({ id: params.cartId }, { email }, authorization) + .pipe(switchMap(() => this.completeCartAndCreateOrder(params.cartId, email, authorization))); } - return this.completeCartAndCreateOrder(params.cartId, guestEmail, authorization); + return this.completeCartAndCreateOrder(params.cartId, email, authorization); }), ); } private completeCartAndCreateOrder( cartId: string, - guestEmail: string | undefined, + email: string | undefined, authorization: string | undefined, ): Observable { // Complete the cart in Medusa (this creates the order) @@ -208,30 +202,20 @@ export class CheckoutService extends Checkout.Service { this.sdk.store.cart.complete(cartId, {}, this.medusaJsService.getStoreApiHeaders(authorization)), ).pipe( switchMap((response: HttpTypes.StoreCompleteCartResponse) => { - const orderId = response.type === 'order' ? response.order?.id : undefined; - if (!orderId) { + if (response.type !== 'order' || !response.order) { return throwError(() => new BadRequestException('Failed to create order from cart')); } - // Get the created order - return this.ordersService.getOrder({ id: orderId }, authorization).pipe( - switchMap((order) => { - if (!order) { - return throwError(() => new NotFoundException(`Order with ID ${orderId} not found`)); - } - - // Update order with guest email if provided - if (guestEmail && !order.email) { - // Note: In a real implementation, you'd update the order with email - // For now, we'll just use the order as-is - } - - // Note: After successful cart completion (type === 'order'), the cart metadata - // is no longer available in the response. Payment redirect URL should be - // obtained from the payment session before cart completion. - return of(mapPlaceOrderResponse(order)); - }), - ); + // 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) { @@ -317,7 +301,7 @@ export class CheckoutService extends Checkout.Service { authorization: string | undefined, ): Observable { // Setup addresses first - return this.setupAddresses( + return this.setAddresses( { cartId: params.cartId }, { shippingAddressId: data.shippingAddressId, @@ -325,14 +309,14 @@ export class CheckoutService extends Checkout.Service { billingAddressId: data.billingAddressId, billingAddress: data.billingAddress, notes: data.notes, - guestEmail: data.guestEmail, + email: data.email, }, authorization, ).pipe( // Setup shipping method if provided switchMap(() => data.shippingMethodId - ? this.setupShippingMethod( + ? this.setShippingMethod( { cartId: params.cartId }, { shippingOptionId: data.shippingMethodId }, authorization, @@ -341,7 +325,7 @@ export class CheckoutService extends Checkout.Service { ), switchMap(() => // Setup payment - this.setupPayment( + this.setPayment( { cartId: params.cartId }, { providerId: data.paymentProviderId, @@ -355,7 +339,7 @@ export class CheckoutService extends Checkout.Service { this.placeOrder( { cartId: params.cartId }, { - guestEmail: data.guestEmail, + email: data.email, }, authorization, ), diff --git a/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts index d8f1e97e0..1497c3ca5 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts @@ -10,10 +10,7 @@ export function mapCustomerAddress( id: medusaAddress.id, customerId, label: medusaAddress.first_name ? `${medusaAddress.first_name} ${medusaAddress.last_name}` : undefined, - isDefault: - (medusaAddress as unknown as Record).is_default === true || - medusaAddress.is_default_shipping || - medusaAddress.is_default_billing, + isDefault: medusaAddress.is_default_shipping || medusaAddress.is_default_billing, address: { firstName: medusaAddress.first_name, lastName: medusaAddress.last_name, @@ -46,8 +43,8 @@ export function mapCustomerAddresses( export function mapAddressToMedusa(address: Models.Address.Address): HttpTypes.StoreCreateCustomerAddress { return { - first_name: (address.firstName || 'Customer').trim(), - last_name: (address.lastName || 'Name').trim(), + 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(), diff --git a/packages/integrations/medusajs/src/modules/customers/customers.service.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.ts index 85727775f..b5572c7c4 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.service.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.ts @@ -237,7 +237,7 @@ export class CustomersService extends Customers.Service { return from( this.sdk.store.customer.updateAddress( params.id, - { is_default_shipping: true } as unknown as HttpTypes.StoreUpdateCustomerAddress, + { is_default_shipping: true } as Partial, {}, this.medusaJsService.getStoreApiHeaders(authorization), ), 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 9d1af57d8..d6a6683eb 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts @@ -36,7 +36,6 @@ describe('OrdersService', () => { let mockAuthService: { getCustomerId: ReturnType }; let mockConfig: { get: ReturnType }; let mockLogger: { debug: ReturnType }; - let mockHttpClient: Record>; beforeEach(() => { vi.restoreAllMocks(); @@ -54,7 +53,6 @@ describe('OrdersService', () => { get: vi.fn((key: string) => (key === 'DEFAULT_CURRENCY' ? DEFAULT_CURRENCY : '')), }; mockLogger = { debug: vi.fn() }; - mockHttpClient = {}; service = new OrdersService( mockConfig as unknown as ConfigService, diff --git a/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts index 19e51c159..93099d874 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts @@ -37,7 +37,7 @@ export function mapPaymentSession( status: mapPaymentSessionStatus(medusaSession.status), redirectUrl: medusaSession.data?.redirect_url as string | undefined, clientSecret: medusaSession.data?.client_secret as string | undefined, - expiresAt: (medusaSession as unknown as Record).expires_at as string | undefined, + expiresAt: undefined, // Medusa Store API does not expose expires_at on payment sessions metadata: medusaSession.data as Record | undefined, }; } diff --git a/packages/integrations/medusajs/src/modules/payments/payments.service.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.ts index 8d3066f79..eb573c1d7 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.service.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.ts @@ -153,17 +153,13 @@ export class PaymentsService extends Payments.Service { return from( this.httpClient - .post<{ - payment_session: { - id: string; - status: string; - provider_id: string; - data: unknown; - expires_at?: string; - }; - }>(`${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, updatePayload, { - headers: this.medusaJsService.getStoreApiHeaders(authorization), - }) + .post<{ payment_session: HttpTypes.StorePaymentSession }>( + `${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, + updatePayload, + { + headers: this.medusaJsService.getStoreApiHeaders(authorization), + }, + ) .toPromise(), ).pipe( map((response) => { @@ -173,10 +169,7 @@ export class PaymentsService extends Payments.Service { // We don't have cart ID here, so we'll use empty string // In practice, this should be retrieved from the session or stored context const cartId = ''; - return mapPaymentSession( - response.data.payment_session as unknown as HttpTypes.StorePaymentSession, - cartId, - ); + return mapPaymentSession(response.data.payment_session, cartId); }), catchError((error) => { if (error.response?.status === 404) { diff --git a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts index d06fa8678..d9b7bdec4 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts @@ -85,11 +85,14 @@ export const mapService = ( }; }; -const mapAddress = (address: AddressDTO): Models.Address.Address | undefined => { +/** AddressDTO with first_name/last_name as returned by the custom resources API */ +type AddressDTOWithNames = AddressDTO & { first_name?: string; last_name?: string }; + +const mapAddress = (address: AddressDTOWithNames): Models.Address.Address | undefined => { if (!address) return undefined; return { - firstName: (address as unknown as { first_name?: string }).first_name, - lastName: (address as unknown as { last_name?: string }).last_name, + firstName: address.first_name, + lastName: address.last_name, country: address.country_code || '', district: address.province || '', region: address.province || '', 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 index bdf094b73..e1a8711f6 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -87,7 +87,7 @@ const PAYMENT_METHODS: Carts.Model.PaymentMethod[] = [ }, ]; -// Read payment method stored in metadata by setupPayment +// 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; @@ -414,6 +414,7 @@ export const updateCart = ( name: data.name ?? cart.name, type: data.type ?? cart.type, regionId: data.regionId ?? cart.regionId, + email: data.email ?? cart.email, notes: data.notes ?? cart.notes, metadata: mergedMetadata, shippingAddress: shippingAddressFromMetadata ?? cart.shippingAddress, diff --git a/packages/integrations/mocked/src/modules/carts/carts.service.ts b/packages/integrations/mocked/src/modules/carts/carts.service.ts index 071a9cb1d..70ba5709c 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.service.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.service.ts @@ -359,9 +359,9 @@ export class CartsService implements Carts.Service { // Build update data const updateData: Carts.Request.UpdateCartBody = { notes: data.notes, + email: data.email, metadata: { ...existingCart.metadata, - ...(data.guestEmail ? { guestEmail: data.guestEmail } : {}), }, }; diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts b/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts index f04f1fbb8..c23d4e9e6 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts @@ -56,7 +56,7 @@ export function mapCheckoutSummary( total: cart.total, }, notes: cart.notes, - guestEmail: cart.metadata?.guestEmail as string | undefined, + email: cart.email, }; } diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts index 533353caa..5cd6456b2 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -18,9 +18,9 @@ export class CheckoutService implements Checkout.Service { private readonly paymentsService: Payments.Service, ) {} - setupAddresses( - params: Checkout.Request.SetupAddressesParams, - data: Checkout.Request.SetupAddressesBody, + setAddresses( + params: Checkout.Request.SetAddressesParams, + data: Checkout.Request.SetAddressesBody, authorization: string | undefined, ): Observable { return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( @@ -40,9 +40,9 @@ export class CheckoutService implements Checkout.Service { ); } - setupShippingMethod( - params: Checkout.Request.SetupShippingMethodParams, - data: Checkout.Request.SetupShippingMethodBody, + setShippingMethod( + params: Checkout.Request.SetShippingMethodParams, + data: Checkout.Request.SetShippingMethodBody, authorization: string | undefined, ): Observable { return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( @@ -68,9 +68,9 @@ export class CheckoutService implements Checkout.Service { ); } - setupPayment( - params: Checkout.Request.SetupPaymentParams, - data: Checkout.Request.SetupPaymentBody, + setPayment( + params: Checkout.Request.SetPaymentParams, + data: Checkout.Request.SetPaymentBody, authorization: string | undefined, ): Observable { return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( @@ -167,11 +167,11 @@ export class CheckoutService implements Checkout.Service { return throwError(() => new BadRequestException('Payment session is required')); } - // Get guest email (from cart metadata or request body) - const guestEmail = data?.guestEmail || (cart.metadata?.guestEmail as string | undefined); + // Get email (from request body or cart) + const email = data?.email || cart.email; // Create order from cart - const order = mapOrderFromCart(cart, guestEmail); + const order = mapOrderFromCart(cart, email); MOCKED_ORDERS.push(order); // Get payment session for redirect URL @@ -195,8 +195,8 @@ export class CheckoutService implements Checkout.Service { data: Checkout.Request.CompleteCheckoutBody, authorization: string | undefined, ): Observable { - // Setup addresses first - return this.setupAddresses( + // Set addresses first + return this.setAddresses( { cartId: params.cartId }, { shippingAddressId: data.shippingAddressId, @@ -204,14 +204,14 @@ export class CheckoutService implements Checkout.Service { billingAddressId: data.billingAddressId, billingAddress: data.billingAddress, notes: data.notes, - guestEmail: data.guestEmail, + email: data.email, }, authorization, ).pipe( - // Setup shipping method if provided + // Set shipping method if provided switchMap(() => data.shippingMethodId - ? this.setupShippingMethod( + ? this.setShippingMethod( { cartId: params.cartId }, { shippingOptionId: data.shippingMethodId }, authorization, @@ -219,8 +219,8 @@ export class CheckoutService implements Checkout.Service { : of(null), ), switchMap(() => - // Setup payment - this.setupPayment( + // Set payment + this.setPayment( { cartId: params.cartId }, { providerId: data.paymentProviderId, @@ -234,7 +234,7 @@ export class CheckoutService implements Checkout.Service { this.placeOrder( { cartId: params.cartId }, { - guestEmail: data.guestEmail, + email: data.email, }, authorization, ), diff --git a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts index 1b95596f7..ab07dcc46 100644 --- a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts @@ -504,7 +504,7 @@ export const mapOrders = (options: Orders.Request.GetOrderListQuery, customerId: }; }; -export function mapOrderFromCart(cart: Carts.Model.Cart, guestEmail?: string): Orders.Model.Order { +export function mapOrderFromCart(cart: Carts.Model.Cart, email?: string): Orders.Model.Order { const now = new Date(); const orderId = `ORD-${Date.now()}`; @@ -553,6 +553,6 @@ export function mapOrderFromCart(cart: Carts.Model.Cart, guestEmail?: string): O billingAddress: cart.billingAddress, shippingMethods: [cart.shippingMethod], customerComment: cart.notes, - email: guestEmail, + email, }; } From 8f3a81c3df2c3904b5b0de47c52d5974d93b35b4 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Fri, 13 Feb 2026 12:08:23 +0100 Subject: [PATCH 04/27] feat: localize payment providers and enhance cart module functionality - Added locale-based grouping for payment providers. - Introduced utility to retrieve localized payment method display info. - Updated cart and checkout services to support locale-based operations. - Enhanced cart item handling with improved product mapping and variant support. - Addressed stricter authentication for customer cart access and actions. --- .../configs/integrations/src/models/carts.ts | 2 +- .../integrations/src/models/checkout.ts | 2 +- .../integrations/src/models/customers.ts | 2 +- .../configs/integrations/src/models/orders.ts | 2 +- .../integrations/src/models/payments.ts | 2 +- .../integrations/src/models/resources.ts | 2 +- .../modules/checkout/checkout.controller.ts | 10 +- .../src/modules/checkout/checkout.request.ts | 2 + .../src/modules/checkout/checkout.service.ts | 2 +- .../modules/payments/payments.controller.ts | 2 +- .../src/modules/payments/payments.request.ts | 1 + .../integrations/mocked/src/integration.ts | 2 +- .../mocked/src/modules/carts/carts.mapper.ts | 386 +++++------------- .../mocked/src/modules/carts/carts.service.ts | 222 ++++++---- .../src/modules/checkout/checkout.mapper.ts | 149 +++++-- .../src/modules/checkout/checkout.service.ts | 8 +- .../src/modules/customers/customers.mapper.ts | 2 +- .../modules/payments/mocks/providers.mock.ts | 121 ++++-- .../src/modules/payments/payments.service.ts | 6 +- 19 files changed, 484 insertions(+), 441 deletions(-) diff --git a/packages/configs/integrations/src/models/carts.ts b/packages/configs/integrations/src/models/carts.ts index 7e461bfcb..b8f1582ed 100644 --- a/packages/configs/integrations/src/models/carts.ts +++ b/packages/configs/integrations/src/models/carts.ts @@ -1,4 +1,4 @@ -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; +import { Config, Integration } from '@o2s/integrations.mocked/integration'; import { ApiConfig } from '@o2s/framework/modules'; diff --git a/packages/configs/integrations/src/models/checkout.ts b/packages/configs/integrations/src/models/checkout.ts index 96661e7e5..e267d6b5c 100644 --- a/packages/configs/integrations/src/models/checkout.ts +++ b/packages/configs/integrations/src/models/checkout.ts @@ -1,4 +1,4 @@ -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; +import { Config, Integration } from '@o2s/integrations.mocked/integration'; import { ApiConfig } from '@o2s/framework/modules'; diff --git a/packages/configs/integrations/src/models/customers.ts b/packages/configs/integrations/src/models/customers.ts index 008383b5b..80f726acf 100644 --- a/packages/configs/integrations/src/models/customers.ts +++ b/packages/configs/integrations/src/models/customers.ts @@ -1,4 +1,4 @@ -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; +import { Config, Integration } from '@o2s/integrations.mocked/integration'; import { ApiConfig } from '@o2s/framework/modules'; diff --git a/packages/configs/integrations/src/models/orders.ts b/packages/configs/integrations/src/models/orders.ts index 137dfb770..413ebd794 100644 --- a/packages/configs/integrations/src/models/orders.ts +++ b/packages/configs/integrations/src/models/orders.ts @@ -1,4 +1,4 @@ -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; +import { Config, Integration } from '@o2s/integrations.mocked/integration'; import { ApiConfig } from '@o2s/framework/modules'; diff --git a/packages/configs/integrations/src/models/payments.ts b/packages/configs/integrations/src/models/payments.ts index 955de46de..b03c77fa5 100644 --- a/packages/configs/integrations/src/models/payments.ts +++ b/packages/configs/integrations/src/models/payments.ts @@ -1,4 +1,4 @@ -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; +import { Config, Integration } from '@o2s/integrations.mocked/integration'; import { ApiConfig } from '@o2s/framework/modules'; diff --git a/packages/configs/integrations/src/models/resources.ts b/packages/configs/integrations/src/models/resources.ts index 429c258aa..b0ac78b10 100644 --- a/packages/configs/integrations/src/models/resources.ts +++ b/packages/configs/integrations/src/models/resources.ts @@ -1,4 +1,4 @@ -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; +import { Config, Integration } from '@o2s/integrations.mocked/integration'; import { ApiConfig } from '@o2s/framework/modules'; diff --git a/packages/framework/src/modules/checkout/checkout.controller.ts b/packages/framework/src/modules/checkout/checkout.controller.ts index b523ff942..0e8a9eb23 100644 --- a/packages/framework/src/modules/checkout/checkout.controller.ts +++ b/packages/framework/src/modules/checkout/checkout.controller.ts @@ -40,12 +40,18 @@ export class CheckoutController { @Get(':cartId/shipping-options') getShippingOptions(@Param() params: Request.GetShippingOptionsParams, @Headers() headers: AppHeaders) { - return this.checkoutService.getShippingOptions(params, headers.authorization); + 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, headers.authorization); + return this.checkoutService.getCheckoutSummary( + { ...params, locale: headers['x-locale'] }, + headers.authorization, + ); } @Post(':cartId/place-order') diff --git a/packages/framework/src/modules/checkout/checkout.request.ts b/packages/framework/src/modules/checkout/checkout.request.ts index afca01f86..b6a768ed0 100644 --- a/packages/framework/src/modules/checkout/checkout.request.ts +++ b/packages/framework/src/modules/checkout/checkout.request.ts @@ -32,10 +32,12 @@ export class SetPaymentBody { 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 { diff --git a/packages/framework/src/modules/checkout/checkout.service.ts b/packages/framework/src/modules/checkout/checkout.service.ts index f03951949..cdb6e3800 100644 --- a/packages/framework/src/modules/checkout/checkout.service.ts +++ b/packages/framework/src/modules/checkout/checkout.service.ts @@ -42,7 +42,7 @@ export abstract class CheckoutService { authorization?: string, ): Observable; - // Get available shipping options for a cart + // Get available shipping options for a cart (params.locale for localized names/descriptions) abstract getShippingOptions( params: Checkout.Request.GetShippingOptionsParams, authorization?: string, diff --git a/packages/framework/src/modules/payments/payments.controller.ts b/packages/framework/src/modules/payments/payments.controller.ts index 14166dce8..1d3102520 100644 --- a/packages/framework/src/modules/payments/payments.controller.ts +++ b/packages/framework/src/modules/payments/payments.controller.ts @@ -13,7 +13,7 @@ export class PaymentsController { @Get('providers') getProviders(@Query() params: Request.GetProvidersParams, @Headers() headers: AppHeaders) { - return this.paymentService.getProviders(params, headers.authorization); + return this.paymentService.getProviders({ ...params, locale: headers['x-locale'] }, headers.authorization); } @Post('sessions') diff --git a/packages/framework/src/modules/payments/payments.request.ts b/packages/framework/src/modules/payments/payments.request.ts index a0a95d752..268fdd32c 100644 --- a/packages/framework/src/modules/payments/payments.request.ts +++ b/packages/framework/src/modules/payments/payments.request.ts @@ -1,5 +1,6 @@ export class GetProvidersParams { regionId!: string; + locale?: string; // From x-locale header } export class CreateSessionBody { diff --git a/packages/integrations/mocked/src/integration.ts b/packages/integrations/mocked/src/integration.ts index 7a35f9032..0b49fec31 100644 --- a/packages/integrations/mocked/src/integration.ts +++ b/packages/integrations/mocked/src/integration.ts @@ -80,7 +80,7 @@ export const Config: Partial = { carts: { name: 'mocked', service: CartsService, - imports: [Auth.Module], + imports: [Auth.Module, Customers.Module], }, customers: { name: 'mocked', diff --git a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts index e1a8711f6..300e5c869 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -1,91 +1,7 @@ import { Carts, Models, Products } from '@o2s/framework/modules'; -// Product data for generating cart items -const PRODUCT_DATA = [ - { - id: 'PRD-004', - name: 'Rotary Hammer', - price: 100, - currency: 'USD', - type: 'PHYSICAL', - category: 'TOOLS', - sku: 'RH-12345-S-BL', - }, - { - id: 'PRD-005', - name: 'Angle Grinder', - price: 79.99, - currency: 'USD', - type: 'PHYSICAL', - category: 'TOOLS', - sku: 'AG-12345-S-BL', - }, - { - id: 'PRD-006', - name: 'Cordless Drill', - price: 129.99, - currency: 'USD', - type: 'PHYSICAL', - category: 'TOOLS', - sku: 'CD-12345-S-BL', - }, - { - id: 'PRD-007', - name: 'Laser Measure', - price: 149.99, - currency: 'USD', - type: 'PHYSICAL', - category: 'TOOLS', - sku: 'LM-12345-S-BL', - }, - { - id: 'PRD-008', - name: 'Safety Glasses', - price: 19.99, - currency: 'USD', - type: 'PHYSICAL', - category: 'SAFETY', - sku: 'SG-12345-S-BL', - }, -]; - -// Shipping methods -const SHIPPING_METHODS = [ - { - id: 'SHIP-001', - name: 'Standard Shipping', - description: '3-5 business days', - price: 10, - }, - { - id: 'SHIP-002', - name: 'Express Shipping', - description: '1-2 business days', - price: 20, - }, -]; - -// Payment methods -const PAYMENT_METHODS: Carts.Model.PaymentMethod[] = [ - { - id: 'PAY-001', - name: 'Credit Card', - description: 'Visa, Mastercard, American Express', - type: 'CREDIT_CARD', - }, - { - id: 'PAY-002', - name: 'PayPal', - description: 'Pay with your PayPal account', - type: 'PAYPAL', - }, - { - id: 'PAY-003', - name: 'Bank Transfer', - description: 'Direct bank transfer', - type: 'BANK_TRANSFER', - }, -]; +import { getMockProviderById, getPaymentMethodDisplay } from '../payments/mocks/providers.mock'; +import { mapProduct } from '../products/products.mapper'; // Read payment method stored in metadata by setPayment const mapPaymentMethodFromMetadata = (metadata: Record): Carts.Model.PaymentMethod | undefined => { @@ -122,195 +38,55 @@ const PROMOTIONS: Carts.Model.Promotion[] = [ }, ]; -// Helper functions -const getRandomInt = (min: number, max: number): number => { - return Math.floor(Math.random() * (max - min + 1)) + min; -}; - const formatDate = (date: Date): string => { return date.toISOString(); }; -// Function to generate a cart item -const generateCartItem = (itemIndex: number): Carts.Model.CartItem => { - const productIndex = getRandomInt(0, PRODUCT_DATA.length - 1); - const product = PRODUCT_DATA[productIndex]!; - const quantity = getRandomInt(1, 3); - const price = product.price; +// Build cart item from product (shared by addCartItem and generateCartItem) +const buildCartItemFromProduct = ( + product: Products.Model.Product, + quantity: number, + itemIndex: number, + currency: Models.Price.Currency, + metadata: Record = {}, +): Carts.Model.CartItem => { + const price = product.price?.value ?? 0; const subtotal = price * quantity; - const discountTotal = 0; - const total = subtotal - discountTotal; return { id: `ITEM-${itemIndex.toString().padStart(3, '0')}`, productId: product.id, variantId: undefined, quantity, - price: { - value: price, - currency: product.currency as Carts.Model.Cart['currency'], - }, - subtotal: { - value: subtotal, - currency: product.currency as Carts.Model.Cart['currency'], - }, - discountTotal: { - value: discountTotal, - currency: product.currency as Carts.Model.Cart['currency'], - }, - total: { - value: total, - currency: product.currency as Carts.Model.Cart['currency'], - }, + price: { value: price, currency }, + subtotal: { value: subtotal, currency }, + discountTotal: { value: 0, currency }, + total: { value: subtotal, currency }, unit: 'PCS', - currency: product.currency as Carts.Model.Cart['currency'], + currency, product: { id: product.id, - sku: product.sku, + sku: product.sku ?? '', name: product.name, - description: `Description for ${product.name}`, - shortDescription: `Short description for ${product.name}`, - image: { - url: 'https://raw.githubusercontent.com/o2sdev/openselfservice/refs/heads/main/packages/integrations/mocked/public/images/empty.jpg', - width: 640, - height: 656, - alt: product.name, - }, - price: { - value: product.price, - currency: product.currency as Carts.Model.Cart['currency'], - }, - link: `https://example.com/products/${product.id.toLowerCase()}`, - type: product.type as Products.Model.Product['type'], - category: product.category as Products.Model.Product['category'], - tags: [ - { - label: 'New', - variant: 'secondary', - }, - ], - }, - metadata: {}, - }; -}; - -// Function to generate a cart -const generateCart = ( - cartId: string, - customerId: string | undefined, - type: Carts.Model.CartType, - name?: string, -): Carts.Model.Cart => { - const now = new Date(); - const createdAt = new Date(now.getTime() - getRandomInt(1, 7) * 24 * 60 * 60 * 1000); - const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days from now - - const numItems = getRandomInt(1, 4); - const items: Carts.Model.CartItem[] = []; - - let subtotal = 0; - for (let i = 0; i < numItems; i++) { - const item = generateCartItem(i); - items.push(item); - subtotal += item.total?.value || 0; - } - - const currency = items[0]?.currency || 'USD'; - const shippingMethod = SHIPPING_METHODS[0]!; - const shippingTotal = shippingMethod.price; - const discountTotal = type === 'ACTIVE' ? Math.round(subtotal * 0.1 * 100) / 100 : 0; - const taxTotal = Math.round((subtotal - discountTotal) * 0.23 * 100) / 100; // 23% tax - const total = subtotal + shippingTotal - discountTotal + taxTotal; - - return { - id: cartId, - customerId, - name: name || (type === 'SAVED' ? 'Saved Cart' : undefined), - type, - createdAt: formatDate(createdAt), - updatedAt: formatDate(now), - expiresAt: formatDate(expiresAt), - regionId: 'reg_01JS1JBXAPK2BTV504ASWVFC4S', // Mock region ID - currency, - items: { - data: items, - total: items.length, - }, - subtotal: { - value: subtotal, - currency, + 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 ?? [], }, - discountTotal: { - value: discountTotal, - currency, - }, - taxTotal: { - value: taxTotal, - currency, - }, - shippingTotal: { - value: shippingTotal, - currency, - }, - total: { - value: total, - currency, - }, - shippingAddress: { - country: 'US', - streetName: 'Main St', - streetNumber: '123', - city: 'Anytown', - region: 'CA', - postalCode: '12345', - phone: '555-123-4567', - email: 'john.doe@example.com', - }, - billingAddress: { - country: 'US', - streetName: 'Main St', - streetNumber: '123', - city: 'Anytown', - region: 'CA', - postalCode: '12345', - phone: '555-123-4567', - email: 'john.doe@example.com', - }, - shippingMethod: { - id: shippingMethod.id, - name: shippingMethod.name, - description: shippingMethod.description, - total: { - value: shippingMethod.price, - currency, - }, - }, - paymentMethod: PAYMENT_METHODS[0], - promotions: type === 'ACTIVE' ? [PROMOTIONS[0]!] : [], - metadata: {}, - notes: undefined, + metadata, }; }; -// Generate mocked carts -const MOCKED_CARTS: Carts.Model.Cart[] = [ - // Active cart for authenticated customer - generateCart('CART-001', 'cus_01KH3J08TY40PYGVEG3A04CP8R', 'ACTIVE'), - // Saved carts for authenticated customer - generateCart('CART-002', 'cus_01KH3J08TY40PYGVEG3A04CP8R', 'SAVED', 'Wishlist'), - generateCart('CART-003', 'cus_01KH3J08TY40PYGVEG3A04CP8R', 'SAVED', 'Work Equipment'), - // Abandoned cart - generateCart('CART-004', 'cus_01KH3J08TY40PYGVEG3A04CP8R', 'ABANDONED'), - // Guest cart (no customer ID) - generateCart('CART-005', undefined, 'ACTIVE'), -]; - -// In-memory store for carts (for mutations) -let cartsStore = [...MOCKED_CARTS]; +// 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 = [...MOCKED_CARTS]; + cartsStore = []; }; // Get cart by ID @@ -353,7 +129,7 @@ export const mapCarts = (query: Carts.Request.GetCartListQuery, customerId?: str // Create a new cart export const createCart = (data: Carts.Request.CreateCartBody): Carts.Model.Cart => { - const newId = `CART-${(cartsStore.length + 1).toString().padStart(3, '0')}`; + const newId = `CART-${crypto.randomUUID()}`; const now = new Date(); const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); @@ -384,10 +160,16 @@ export const createCart = (data: Carts.Request.CreateCartBody): Carts.Model.Cart 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: Carts.Request.UpdateCartBody, + data: UpdateCartData, ): Carts.Model.Cart | undefined => { const cartIndex = cartsStore.findIndex((cart) => cart.id === params.id); if (cartIndex === -1) return undefined; @@ -404,9 +186,17 @@ export const updateCart = ( const shippingAddressFromMetadata = mergedMetadata.shippingAddress as Models.Address.Address | undefined; const billingAddressFromMetadata = mergedMetadata.billingAddress as Models.Address.Address | undefined; - // Resolve payment method: from paymentMethodId, from metadata paymentProviderId, or keep existing + // 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 - ? PAYMENT_METHODS.find((pm) => pm.id === data.paymentMethodId) + ? (getPaymentMethodDisplay(data.paymentMethodId) as Carts.Model.PaymentMethod | undefined) : (mapPaymentMethodFromMetadata(mergedMetadata) ?? cart.paymentMethod); const updatedCart: Carts.Model.Cart = { @@ -419,6 +209,8 @@ export const updateCart = ( 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()), }; @@ -443,41 +235,52 @@ export const findActiveCartByCustomerId = (customerId: string | undefined): Cart return cartsStore.find((cart) => cart.customerId === customerId && cart.type === 'ACTIVE'); }; +const matchesProductAndVariant = (item: Carts.Model.CartItem, productId: string, variantId?: string): boolean => { + if (item.productId !== productId) return false; + const itemVariant = item.variantId ?? undefined; + const reqVariant = variantId ?? undefined; + return itemVariant === reqVariant; +}; + export const addCartItem = (cartId: string, data: Carts.Request.AddCartItemBody): Carts.Model.Cart | undefined => { const cartIndex = cartsStore.findIndex((cart) => cart.id === cartId); if (cartIndex === -1) return undefined; + let product: Products.Model.Product; + try { + product = mapProduct(data.productId); + } catch { + return undefined; // Product not found + } + const cart = cartsStore[cartIndex]!; - const product = PRODUCT_DATA.find((p) => p.id === data.productId); - - if (!product) return undefined; - - const newItem: Carts.Model.CartItem = { - id: `ITEM-${cart.items.data.length.toString().padStart(3, '0')}`, - productId: data.productId, - variantId: data.variantId, - quantity: data.quantity, - price: { value: product.price, currency: cart.currency }, - subtotal: { value: product.price * data.quantity, currency: cart.currency }, - discountTotal: { value: 0, currency: cart.currency }, - total: { value: product.price * data.quantity, currency: cart.currency }, - unit: 'PCS', - currency: cart.currency, - product: { - id: product.id, - sku: product.sku, - name: product.name, - description: `Description for ${product.name}`, - price: { value: product.price, currency: cart.currency }, - link: `https://example.com/products/${product.id.toLowerCase()}`, - type: product.type as Products.Model.Product['type'], - category: product.category, - tags: [], - }, - metadata: data.metadata || {}, - }; - cart.items.data.push(newItem); + // Find existing item with same product and variant — merge quantity instead of adding duplicate + const existingIndex = cart.items.data.findIndex((item) => + matchesProductAndVariant(item, data.productId, data.variantId), + ); + + 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.items.data.length, + cart.currency, + data.metadata || {}, + ); + newItem.variantId = data.variantId; + cart.items.data.push(newItem); + } + cart.items.total = cart.items.data.length; recalculateCartTotals(cart); cart.updatedAt = formatDate(new Date()); @@ -499,9 +302,14 @@ export const updateCartItem = ( const item = cart.items.data[itemIndex]!; if (data.quantity !== undefined) { - 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.quantity <= 0) { + cart.items.data.splice(itemIndex, 1); + cart.items.total = cart.items.data.length; + } 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; diff --git a/packages/integrations/mocked/src/modules/carts/carts.service.ts b/packages/integrations/mocked/src/modules/carts/carts.service.ts index 70ba5709c..13a976729 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.service.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.service.ts @@ -1,7 +1,9 @@ import { BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; -import { Observable, of } from 'rxjs'; +import { Observable, forkJoin, of, switchMap, throwError } from 'rxjs'; -import { Auth, Carts } from '@o2s/framework/modules'; +import { Auth, Carts, Customers } from '@o2s/framework/modules'; + +import { getShippingOptionById } from '../checkout/checkout.mapper'; import { addCartItem, @@ -20,7 +22,10 @@ import { responseDelay } from '@/utils/delay'; @Injectable() export class CartsService implements Carts.Service { - constructor(private readonly authService: Auth.Service) {} + constructor( + private readonly authService: Auth.Service, + private readonly customersService: Customers.Service, + ) {} getCart( params: Carts.Request.GetCartParams, @@ -28,15 +33,13 @@ export class CartsService implements Carts.Service { ): Observable { const cart = mapCart(params); - // For guest carts (no customerId), allow access without auth - if (cart && !cart.customerId) { - return of(cart).pipe(responseDelay()); - } - - // For customer carts, verify authorization - if (authorization) { + // 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 && cart.customerId && cart.customerId !== customerId) { + if (cart.customerId !== customerId) { throw new UnauthorizedException('Unauthorized to access this cart'); } } @@ -48,12 +51,12 @@ export class CartsService implements Carts.Service { query: Carts.Request.GetCartListQuery, authorization: string | undefined, ): Observable { - let customerId: string | undefined; - - if (authorization) { - customerId = this.authService.getCustomerId(authorization); + // 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()); } @@ -81,8 +84,11 @@ export class CartsService implements Carts.Service { throw new NotFoundException('Cart not found'); } - // Verify authorization for customer carts - if (existingCart.customerId && authorization) { + // 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'); @@ -104,8 +110,11 @@ export class CartsService implements Carts.Service { throw new NotFoundException('Cart not found'); } - // Verify authorization for customer carts - if (existingCart.customerId && authorization) { + // 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'); @@ -137,8 +146,11 @@ export class CartsService implements Carts.Service { throw new NotFoundException('Cart not found'); } - // Verify authorization for customer carts - if (existingCart.customerId && authorization) { + // 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'); } @@ -200,8 +212,11 @@ export class CartsService implements Carts.Service { throw new NotFoundException('Cart not found'); } - // Verify authorization for customer carts - if (existingCart.customerId && authorization) { + // 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'); @@ -226,8 +241,11 @@ export class CartsService implements Carts.Service { throw new NotFoundException('Cart not found'); } - // Verify authorization for customer carts - if (existingCart.customerId && authorization) { + // 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'); @@ -253,8 +271,11 @@ export class CartsService implements Carts.Service { throw new NotFoundException('Cart not found'); } - // Verify authorization for customer carts - if (existingCart.customerId && authorization) { + // 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'); @@ -279,8 +300,11 @@ export class CartsService implements Carts.Service { throw new NotFoundException('Cart not found'); } - // Verify authorization for customer carts - if (existingCart.customerId && authorization) { + // 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'); @@ -321,8 +345,11 @@ export class CartsService implements Carts.Service { throw new NotFoundException(`Cart with ID ${params.cartId} not found`); } - // Verify authorization for customer carts - if (cart.customerId && authorization) { + // 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'); @@ -345,54 +372,86 @@ export class CartsService implements Carts.Service { const existingCart = mapCart({ id: params.cartId }); if (!existingCart) { - throw new NotFoundException('Cart not found'); + return throwError(() => new NotFoundException('Cart not found')); } - // Verify authorization for customer carts - if (existingCart.customerId && authorization) { + // 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) { - throw new UnauthorizedException('Unauthorized to update this cart'); + return throwError(() => new UnauthorizedException('Unauthorized to update this cart')); } } - // Build update data - const updateData: Carts.Request.UpdateCartBody = { - notes: data.notes, - email: data.email, - metadata: { - ...existingCart.metadata, - }, - }; - - // Set addresses if provided - if (data.shippingAddressId) { - updateData.shippingAddressId = data.shippingAddressId; - } - if (data.shippingAddress) { - // Store in metadata for mocked implementation - updateData.metadata = { - ...updateData.metadata, - shippingAddress: data.shippingAddress, - }; - } - if (data.billingAddressId) { - updateData.billingAddressId = data.billingAddressId; - } - if (data.billingAddress) { - // Store in metadata for mocked implementation - updateData.metadata = { - ...updateData.metadata, - billingAddress: data.billingAddress, - }; + // 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 cart = updateCart({ id: params.cartId }, updateData); - if (!cart) { - throw new NotFoundException('Cart not found'); - } + const resolveAddresses$ = (): Observable<{ + shippingAddress?: Carts.Model.Cart['shippingAddress']; + billingAddress?: Carts.Model.Cart['billingAddress']; + }> => { + if (!authorization) { + return of({ + shippingAddress: data.shippingAddress, + billingAddress: data.billingAddress, + }); + } - return of(cart).pipe(responseDelay()); + 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( @@ -406,8 +465,11 @@ export class CartsService implements Carts.Service { throw new NotFoundException('Cart not found'); } - // Verify authorization for customer carts - if (existingCart.customerId && authorization) { + // 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'); @@ -419,7 +481,23 @@ export class CartsService implements Carts.Service { throw new BadRequestException('Cart must have items before adding shipping method'); } - const cart = updateCart({ id: params.cartId }, { shippingMethodId: data.shippingOptionId }); + 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'); } diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts b/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts index c23d4e9e6..dc13c1bf3 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.mapper.ts @@ -2,9 +2,12 @@ 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'); @@ -42,12 +45,26 @@ export function mapCheckoutSummary( 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: { + ...cart, + shippingMethod, + paymentMethod, + }, shippingAddress: cart.shippingAddress, billingAddress: cart.billingAddress, - shippingMethod: cart.shippingMethod, - paymentMethod: cart.paymentMethod, + shippingMethod, + paymentMethod, totals: { subtotal: cart.subtotal, shipping: cart.shippingTotal, @@ -70,33 +87,105 @@ export function mapPlaceOrderResponse( }; } -const MOCK_SHIPPING_OPTIONS: Orders.Model.ShippingMethod[] = [ - { - 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' }, - }, -]; - -export function mapShippingOptions(): Checkout.Model.ShippingOptions { +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: MOCK_SHIPPING_OPTIONS, - total: MOCK_SHIPPING_OPTIONS.length, + 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 index 5cd6456b2..bc62ee3eb 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -133,10 +133,10 @@ export class CheckoutService implements Checkout.Service { if (paymentSessionId) { return this.paymentsService .getSession({ id: paymentSessionId }, authorization) - .pipe(map((session) => mapCheckoutSummary(cart, session))); + .pipe(map((session) => mapCheckoutSummary(cart, session, params.locale))); } - return of(mapCheckoutSummary(cart)); + return of(mapCheckoutSummary(cart, undefined, params.locale)); }), responseDelay(), ); @@ -184,10 +184,10 @@ export class CheckoutService implements Checkout.Service { } getShippingOptions( - _params: Checkout.Request.GetShippingOptionsParams, + params: Checkout.Request.GetShippingOptionsParams, _authorization?: string, ): Observable { - return of(mapShippingOptions()).pipe(responseDelay()); + return of(mapShippingOptions(params.locale)).pipe(responseDelay()); } completeCheckout( diff --git a/packages/integrations/mocked/src/modules/customers/customers.mapper.ts b/packages/integrations/mocked/src/modules/customers/customers.mapper.ts index 7a75c4eaa..0dc1a8de5 100644 --- a/packages/integrations/mocked/src/modules/customers/customers.mapper.ts +++ b/packages/integrations/mocked/src/modules/customers/customers.mapper.ts @@ -25,7 +25,7 @@ export function createCustomerAddress( customerId: string, ): Customers.Model.CustomerAddress { const now = new Date().toISOString(); - const id = `addr-${Date.now()}`; + const id = `addr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; return { id, diff --git a/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts b/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts index 6a0d5ee8a..20c188a94 100644 --- a/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts +++ b/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts @@ -1,38 +1,97 @@ import { Payments } from '@o2s/framework/modules'; -const MOCK_PROVIDERS: Payments.Model.PaymentProvider[] = [ - { - id: 'stripe', - name: 'Stripe', - type: 'STRIPE', - isEnabled: true, - requiresRedirect: true, - config: { - publishableKey: 'pk_test_mock', +type Locale = 'en' | 'de' | 'pl'; + +const PROVIDERS_BY_LOCALE: Record< + Locale, + Array<{ + id: string; + name: string; + type: Payments.Model.PaymentProviderType; + isEnabled: boolean; + requiresRedirect: boolean; + config?: 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: {}, }, - }, - { - id: 'paypal', - name: 'PayPal', - type: 'PAYPAL', - isEnabled: true, - requiresRedirect: true, - config: {}, - }, - { - id: 'system', - name: 'System Payment', - type: 'SYSTEM', - isEnabled: true, - requiresRedirect: false, - config: {}, - }, -]; + ], +}; -export function getMockProviders(): Payments.Model.PaymentProvider[] { - return MOCK_PROVIDERS; +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); } -export function getMockProviderById(id: string): Payments.Model.PaymentProvider | undefined { - return MOCK_PROVIDERS.find((provider) => provider.id === id); +/** Map provider type to cart PaymentMethodType. */ +const providerTypeToPaymentMethodType = ( + t: Payments.Model.PaymentProviderType, +): '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.service.ts b/packages/integrations/mocked/src/modules/payments/payments.service.ts index f859097b9..5ea9c8801 100644 --- a/packages/integrations/mocked/src/modules/payments/payments.service.ts +++ b/packages/integrations/mocked/src/modules/payments/payments.service.ts @@ -12,10 +12,10 @@ export class PaymentsService implements Payments.Service { private sessions: Payments.Model.PaymentSession[] = []; getProviders( - _params: Payments.Request.GetProvidersParams, + params: Payments.Request.GetProvidersParams, _authorization: string | undefined, ): Observable { - const providers = getMockProviders(); + const providers = getMockProviders(params.locale); return of(mapPaymentProviders(providers)).pipe(responseDelay()); } @@ -23,7 +23,7 @@ export class PaymentsService implements Payments.Service { data: Payments.Request.CreateSessionBody, _authorization: string | undefined, ): Observable { - const provider = getMockProviderById(data.providerId); + const provider = getMockProviderById(data.providerId, 'en'); if (!provider) { return throwError(() => new NotFoundException(`Payment provider with ID ${data.providerId} not found`)); From f350204b7be6b61dcfddc23e76ebc877ad7868df Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Fri, 13 Feb 2026 15:36:58 +0100 Subject: [PATCH 05/27] feat(integration.medusajs): add extensive test coverage for carts, products, customers, and orders --- .../src/modules/carts/carts.mapper.spec.ts | 184 +++++++ .../src/modules/carts/carts.service.spec.ts | 520 ++++++++++++++++++ .../modules/checkout/checkout.mapper.spec.ts | 203 +++++++ .../src/modules/checkout/checkout.mapper.ts | 3 +- .../modules/checkout/checkout.service.spec.ts | 422 ++++++++++++++ .../customers/customers.mapper.spec.ts | 115 ++++ .../customers/customers.service.spec.ts | 283 ++++++++++ .../modules/medusajs/medusajs.service.spec.ts | 41 +- .../src/modules/orders/orders.service.spec.ts | 92 +++- .../modules/payments/payments.mapper.spec.ts | 135 +++++ .../modules/payments/payments.service.spec.ts | 281 ++++++++++ .../modules/products/products.service.spec.ts | 109 ++-- .../modules/utils/handle-http-error.spec.ts | 8 + .../src/modules/utils/handle-http-error.ts | 14 +- 14 files changed, 2329 insertions(+), 81 deletions(-) create mode 100644 packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/customers/customers.mapper.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts create mode 100644 packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts 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..37479ec98 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts @@ -0,0 +1,184 @@ +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 product_id, variant_id, 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]?.productId).toBe('prod_1'); + expect(result.items.data[0]?.variantId).toBe('var_1'); + 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', + type: 'CREDIT_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?.type).toBe('CREDIT_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.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts new file mode 100644 index 000000000..9f91c71c5 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -0,0 +1,520 @@ +import { HttpTypes } from '@medusajs/types'; +import { BadRequestException } from '@nestjs/common'; +import { 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 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; + }; + }; + }; + 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(), + }, + }, + }; + 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', {}, 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 }, + {}, + expect.any(Object), + ); + }); + }); + + describe('addCartItem', () => { + it('should throw BadRequestException when variantId 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', () => { + expect(() => + service.addCartItem( + { variantId: 'var_1', quantity: 1 } as Carts.Request.AddCartItemBody, + 'Bearer token', + ), + ).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: [{ id: 'item_1' }] }, + }); + mockAuthService.getCustomerId.mockReturnValue(undefined); + + const result = await firstValueFrom( + service.addCartItem( + { cartId: 'cart_1', variantId: 'var_1', quantity: 2 } as Carts.Request.AddCartItemBody, + 'Bearer token', + ), + ); + + expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith('cart_1', {}, expect.any(Object)); + expect(mockSdk.store.cart.createLineItem).toHaveBeenCalledWith( + 'cart_1', + { variant_id: 'var_1', quantity: 2, metadata: undefined }, + {}, + 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', variantId: 'var_1', 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( + { variantId: 'var_1', quantity: 2, currency: 'EUR' } as Carts.Request.AddCartItemBody, + undefined, + ), + ); + + expect(mockSdk.store.cart.create).toHaveBeenCalledWith( + { currency_code: 'eur', region_id: undefined, metadata: undefined }, + {}, + 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( + { variantId: 'var_1', 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 }, + {}, + 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', 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' }), + }), + {}, + 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: [{ id: 'item_1', quantity: 1 }], + 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' }), + }), + {}, + 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: [{ id: 'item_1', quantity: 1 }], + }; + 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' }, + {}, + 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 cart.update with promo_codes', async () => { + mockSdk.store.cart.update.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.store.cart.update).toHaveBeenCalledWith( + 'cart_1', + { promo_codes: ['SAVE10'] }, + {}, + expect.any(Object), + ); + expect(result?.id).toBe('cart_1'); + }); + }); + + describe('removePromotion', () => { + it('should retrieve cart, filter promotion, update with remaining codes', async () => { + const cartWithPromos = { + ...minimalCart, + promotions: [ + { id: 'promo_1', code: 'SAVE10' }, + { id: 'promo_2', code: 'FREE_SHIP' }, + ], + }; + mockSdk.store.cart.retrieve.mockResolvedValue({ cart: cartWithPromos }); + mockSdk.store.cart.update.mockResolvedValue({ cart: minimalCart }); + + const result = await firstValueFrom( + service.removePromotion( + { cartId: 'cart_1', promotionId: 'promo_1' } as Carts.Request.RemovePromotionParams, + 'Bearer token', + ), + ); + + expect(mockSdk.store.cart.update).toHaveBeenCalledWith( + 'cart_1', + { promo_codes: ['FREE_SHIP'] }, + {}, + expect.any(Object), + ); + expect(result).toBeDefined(); + }); + }); + + describe('prepareCheckout', () => { + it('should return cart when valid', async () => { + const cartWithItems = { + ...minimalCart, + items: [{ id: 'item_1', quantity: 1 }], + }; + 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/checkout/checkout.mapper.spec.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts new file mode 100644 index 000000000..00eacb875 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts @@ -0,0 +1,203 @@ +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', + type: 'OTHER', + }; + 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 throw when option has no currency', () => { + const options = [ + { + id: 'opt_1', + name: 'Broken', + amount: 500, + calculated_price: { calculated_amount: 500, currency_code: undefined }, + }, + ] as unknown as Parameters[0]; + expect(() => mapShippingOptions(options, 'EUR')).toThrow(BadRequestException); + expect(() => mapShippingOptions(options, 'EUR')).toThrow('no currency information'); + }); + }); +}); diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts index 8698a0bf0..23729229e 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts @@ -96,11 +96,10 @@ function mapShippingOption( if (!currencyCode) { throw new BadRequestException(`Shipping option ${option.id} has no currency information`); } + const currency = currencyCode as Models.Price.Currency; const amountWithoutTax = calculatedPrice?.calculated_amount_without_tax ?? amount; - const currency = currencyCode as Models.Price.Currency; - return { id: option.id, name: option.name, 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..2c6e4dcee --- /dev/null +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts @@ -0,0 +1,422 @@ +import { BadRequestException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom, of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Auth, Carts, Checkout, Customers, 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, + {} as unknown as Auth.Service, + mockCartsService as unknown as Carts.Service, + {} as unknown as Customers.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/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.service.spec.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts new file mode 100644 index 000000000..e9bd4b7e0 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts @@ -0,0 +1,283 @@ +import { HttpTypes } from '@medusajs/types'; +import { 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', () => { + expect(() => service.getAddresses(undefined)).toThrow(UnauthorizedException); + expect(() => service.getAddresses(undefined)).toThrow('Authentication required'); + }); + + it('should throw UnauthorizedException when getCustomerId returns undefined', () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + expect(() => service.getAddresses('Bearer token')).toThrow(UnauthorizedException); + expect(() => service.getAddresses('Bearer token')).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', () => { + expect(() => service.getAddress({ id: 'addr_1' } as Customers.Request.GetAddressParams, undefined)).toThrow( + UnauthorizedException, + ); + }); + + it('should throw UnauthorizedException when getCustomerId returns undefined', () => { + mockAuthService.getCustomerId.mockReturnValue(undefined); + expect(() => + service.getAddress({ id: 'addr_1' } as Customers.Request.GetAddressParams, 'Bearer token'), + ).toThrow(UnauthorizedException); + }); + + 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', () => { + expect(() => + service.createAddress({ address: {} } as Customers.Request.CreateAddressBody, undefined), + ).toThrow(UnauthorizedException); + }); + + it('should call createAddress SDK and return mapped address', async () => { + mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.createAddress.mockResolvedValue({ + customer: { addresses: [minimalAddress] }, + } 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'); + mockSdk.store.customer.createAddress.mockResolvedValue({ + customer: { addresses: [{ ...minimalAddress, id: 'addr_new' }] }, + } as unknown as HttpTypes.StoreCustomerResponse); + mockSdk.store.customer.updateAddress.mockResolvedValue({ + customer: { addresses: [{ ...minimalAddress, id: 'addr_new', is_default_shipping: 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 }), + {}, + expect.any(Object), + ); + expect(result).toBeDefined(); + }); + }); + + describe('updateAddress', () => { + it('should throw UnauthorizedException when auth is missing', () => { + expect(() => + service.updateAddress({ id: 'addr_1' }, {} as Customers.Request.UpdateAddressBody, undefined), + ).toThrow(UnauthorizedException); + }); + + 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(); + }); + }); + + describe('deleteAddress', () => { + it('should throw UnauthorizedException when auth is missing', () => { + expect(() => + service.deleteAddress({ id: 'addr_1' } as Customers.Request.DeleteAddressParams, undefined), + ).toThrow(UnauthorizedException); + }); + + 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)); + }); + }); + + describe('setDefaultAddress', () => { + 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 }, + {}, + expect.any(Object), + ); + expect(result).toBeDefined(); + expect(result?.id).toBe('addr_1'); + }); + }); +}); 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 274a28a61..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,46 +31,43 @@ describe('MedusaJsService', () => { }; }); - describe('constructor', () => { - 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), ); - expect(() => new MedusaJsService(mockConfig as unknown as ConfigService)).toThrow( - 'MEDUSAJS_BASE_URL is not defined', - ); + const service = new MedusaJsService(mockConfig as unknown as ConfigService); + 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), ); - expect(() => new MedusaJsService(mockConfig as unknown as ConfigService)).toThrow( - 'MEDUSAJS_PUBLISHABLE_API_KEY is not defined', - ); + const service = new MedusaJsService(mockConfig as unknown as ConfigService); + 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), ); - expect(() => new MedusaJsService(mockConfig as unknown as ConfigService)).toThrow( - 'MEDUSAJS_ADMIN_API_KEY is not defined', - ); + const service = new MedusaJsService(mockConfig as unknown as ConfigService); + 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', () => { - new MedusaJsService(mockConfig as unknown as ConfigService); + it('should create Medusa SDK when getSdk is called', () => { + new MedusaJsService(mockConfig as unknown as ConfigService).getSdk(); - expect(Medusa).toHaveBeenCalledWith({ - baseUrl: 'https://api.medusa.test', - debug: false, - publishableKey: 'pk_test', - apiKey: 'admin_secret', - }); + 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', () => { @@ -78,7 +75,7 @@ describe('MedusaJsService', () => { key === 'LOG_LEVEL' ? 'debug' : defaultConfig(key), ); - new MedusaJsService(mockConfig as unknown as ConfigService); + new MedusaJsService(mockConfig as unknown as ConfigService).getSdk(); expect(Medusa).toHaveBeenCalledWith( expect.objectContaining({ 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 d6a6683eb..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,8 +31,8 @@ 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 }; @@ -40,14 +40,17 @@ describe('OrdersService', () => { 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 : '')), @@ -84,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'); @@ -100,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, @@ -114,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/payments/payments.mapper.spec.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts new file mode 100644 index 000000000..1c12edd04 --- /dev/null +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts @@ -0,0 +1,135 @@ +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('STRIPE'); + expect(result.name).toBe('pp_stripe_123'); + }); + + it('should map type STRIPE when id includes stripe', () => { + const result = mapPaymentProvider({ id: 'pp_stripe_default' } as HttpTypes.StorePaymentProvider); + expect(result.type).toBe('STRIPE'); + }); + + it('should map type PAYPAL when id includes paypal', () => { + const result = mapPaymentProvider({ id: 'pp_paypal_express' } as HttpTypes.StorePaymentProvider); + expect(result.type).toBe('PAYPAL'); + }); + + it('should map type ADYEN when id includes adyen', () => { + const result = mapPaymentProvider({ id: 'pp_adyen_checkout' } as HttpTypes.StorePaymentProvider); + expect(result.type).toBe('ADYEN'); + }); + + it('should map type SYSTEM when id includes system or manual', () => { + expect(mapPaymentProvider({ id: 'pp_system' } as HttpTypes.StorePaymentProvider).type).toBe('SYSTEM'); + expect(mapPaymentProvider({ id: 'pp_manual' } as HttpTypes.StorePaymentProvider).type).toBe('SYSTEM'); + }); + + it('should map type OTHER for unknown provider', () => { + const result = mapPaymentProvider({ id: 'pp_custom_gateway' } as HttpTypes.StorePaymentProvider); + expect(result.type).toBe('OTHER'); + }); + + 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 paginate with offset and limit', () => { + const providers = [ + { id: 'pp_1' } as HttpTypes.StorePaymentProvider, + { id: 'pp_2' } as HttpTypes.StorePaymentProvider, + { id: 'pp_3' } as HttpTypes.StorePaymentProvider, + ]; + const result = mapPaymentProviders(providers, 2, 1); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(3); + expect(result.data[0]?.id).toBe('pp_2'); + expect(result.data[1]?.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.service.spec.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts new file mode 100644 index 000000000..26863ac5a --- /dev/null +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts @@ -0,0 +1,281 @@ +import { HttpTypes } from '@medusajs/types'; +import { BadRequestException, NotFoundException } 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', () => { + expect(() => service.getProviders({} as Payments.Request.GetProvidersParams, 'Bearer token')).toThrow( + BadRequestException, + ); + expect(() => service.getProviders({} as Payments.Request.GetProvidersParams, 'Bearer token')).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 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('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 return of(undefined)', async () => { + const result = await firstValueFrom( + service.getSession({ id: 'ps_1' } as Payments.Request.GetSessionParams, 'Bearer token'), + ); + expect(result).toBeUndefined(); + }); + }); + + describe('updateSession', () => { + it('should post to payment-sessions and return mapped session', async () => { + mockHttpService.post.mockReturnValue({ + toPromise: () => + Promise.resolve({ + data: { payment_session: { ...minimalPaymentSession, id: 'ps_updated' } }, + }), + }); + + const result = await firstValueFrom( + service.updateSession( + { id: 'ps_1' } as Payments.Request.UpdateSessionParams, + { returnUrl: 'https://example.com/return' } as Payments.Request.UpdateSessionBody, + 'Bearer token', + ), + ); + + expect(mockHttpService.post).toHaveBeenCalledWith( + `${BASE_URL}/store/payment-sessions/ps_1`, + { return_url: 'https://example.com/return' }, + expect.objectContaining({ headers: expect.any(Object) }), + ); + expect(result.id).toBe('ps_updated'); + }); + + it('should throw NotFoundException on 404', async () => { + mockHttpService.post.mockReturnValue({ + toPromise: () => Promise.reject({ response: { status: 404 } }), + }); + + await expect( + firstValueFrom( + service.updateSession( + { id: 'missing' } as Payments.Request.UpdateSessionParams, + {} as Payments.Request.UpdateSessionBody, + 'Bearer token', + ), + ), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('cancelSession', () => { + it('should delete payment-sessions and return void', async () => { + mockHttpService.delete.mockReturnValue({ + toPromise: () => Promise.resolve({}), + }); + + await firstValueFrom( + service.cancelSession({ id: 'ps_1' } as Payments.Request.CancelSessionParams, 'Bearer token'), + ); + + expect(mockHttpService.delete).toHaveBeenCalledWith( + `${BASE_URL}/store/payment-sessions/ps_1`, + expect.objectContaining({ headers: expect.any(Object) }), + ); + }); + + it('should throw NotFoundException on 404', async () => { + mockHttpService.delete.mockReturnValue({ + toPromise: () => Promise.reject({ response: { status: 404 } }), + }); + + await expect( + firstValueFrom( + service.cancelSession({ id: 'missing' } as Payments.Request.CancelSessionParams, 'Bearer token'), + ), + ).rejects.toThrow(NotFoundException); + }); + }); +}); 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 691cb53f7..7b8953eb3 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.spec.ts @@ -17,31 +17,42 @@ const mockProductListResponse = { thumbnail: null, categories: [], type: null, + variants: [ + { + id: 'var_1', + sku: 'SKU1', + product_id: 'prod_1', + calculated_price: { calculated_amount: 1999, currency_code: 'eur' }, + }, + ], }, ], count: 1, }; -const mockVariantResponse = { - variant: { - id: 'var_1', - sku: 'SKU1', - product_id: 'prod_1', - product: { - id: 'prod_1', - title: 'Product 1', - description: 'Desc', - subtitle: 'Sub', - thumbnail: null, - type: null, - categories: [], - }, - prices: [{ currency_code: 'eur', amount: 1999 }], +const mockProductResponse = { + product: { + id: 'prod_1', + title: 'Product 1', + description: 'Desc', + subtitle: 'Sub', + thumbnail: null, + type: null, + categories: [], + variants: [ + { + id: 'var_1', + sku: 'SKU1', + product_id: 'prod_1', + calculated_price: { calculated_amount: 1999, currency_code: 'eur' }, + }, + ], }, }; describe('ProductsService', () => { let service: ProductsService; + let mockSdk: { store: { product: { list: ReturnType; retrieve: ReturnType } } }; let mockHttpClient: { get: ReturnType }; let mockMedusaJsService: { getSdk: ReturnType; @@ -53,9 +64,17 @@ describe('ProductsService', () => { beforeEach(() => { vi.restoreAllMocks(); + mockSdk = { + store: { + product: { + list: vi.fn(), + retrieve: vi.fn(), + }, + }, + }; mockHttpClient = { get: vi.fn() }; mockMedusaJsService = { - getSdk: vi.fn(() => ({})), + getSdk: vi.fn(() => mockSdk), getBaseUrl: vi.fn(() => BASE_URL), getMedusaAdminApiHeaders: vi.fn(() => ({ 'x-publishable-api-key': 'pk', @@ -92,16 +111,16 @@ describe('ProductsService', () => { }); describe('getProductList', () => { - it('should call httpClient.get with baseUrl and params and return mapped products', async () => { - mockHttpClient.get.mockReturnValue(of({ data: mockProductListResponse })); + it('should call sdk.store.product.list with params and return mapped products', async () => { + mockSdk.store.product.list.mockResolvedValue(mockProductListResponse); const result = await firstValueFrom(service.getProductList({ limit: 10, offset: 0 })); - expect(mockHttpClient.get).toHaveBeenCalledWith( - `${BASE_URL}/admin/products`, + expect(mockSdk.store.product.list).toHaveBeenCalledWith( expect.objectContaining({ - headers: expect.any(Object), - params: { limit: 10, offset: 0 }, + limit: 10, + offset: 0, + fields: expect.any(String), }), ); expect(result.data).toHaveLength(1); @@ -110,9 +129,8 @@ describe('ProductsService', () => { expect(result.data[0]?.name).toBe('Product 1'); }); - it('should throw NotFoundException when HTTP returns 404', async () => { - const { throwError } = await import('rxjs'); - mockHttpClient.get.mockReturnValue(throwError(() => ({ status: 404 }))); + it('should throw NotFoundException when SDK returns 404', async () => { + mockSdk.store.product.list.mockRejectedValue({ status: 404 }); await expect(firstValueFrom(service.getProductList({ limit: 10, offset: 0 }))).rejects.toThrow( NotFoundException, @@ -121,20 +139,45 @@ describe('ProductsService', () => { }); describe('getProduct', () => { - it('should call httpClient.get for variant URL and return mapped product', async () => { - mockHttpClient.get.mockReturnValue(of({ data: mockVariantResponse })); + it('should call sdk.store.product.retrieve and return mapped product', async () => { + mockSdk.store.product.retrieve.mockResolvedValue(mockProductResponse); const result = await firstValueFrom(service.getProduct({ id: 'prod_1', variantId: 'var_1' })); - expect(mockHttpClient.get).toHaveBeenCalledWith( - `${BASE_URL}/admin/products/prod_1/variants/var_1`, - expect.objectContaining({ - params: { fields: 'product.*' }, - }), + expect(mockSdk.store.product.retrieve).toHaveBeenCalledWith( + 'prod_1', + expect.objectContaining({ fields: expect.any(String) }), ); expect(result.id).toBe('prod_1'); expect(result.variantId).toBe('var_1'); - expect(result.price.value).toBe(1999); + expect(result.price?.value).toBe(1999); + }); + + it('should throw when product has no variants', async () => { + mockSdk.store.product.retrieve.mockResolvedValue({ + product: { ...mockProductResponse.product, variants: [] }, + }); + + await expect( + firstValueFrom( + service.getProduct({ + id: 'prod_1', + } as import('@o2s/framework/modules').Products.Request.GetProductParams), + ), + ).rejects.toThrow('No variants found for product prod_1'); + }); + + it('should throw when variantId does not match any variant', async () => { + mockSdk.store.product.retrieve.mockResolvedValue(mockProductResponse); + + await expect( + firstValueFrom( + service.getProduct({ + id: 'prod_1', + variantId: 'var_nonexistent', + } as import('@o2s/framework/modules').Products.Request.GetProductParams), + ), + ).rejects.toThrow('Variant var_nonexistent not found for product prod_1'); }); }); diff --git a/packages/integrations/medusajs/src/modules/utils/handle-http-error.spec.ts b/packages/integrations/medusajs/src/modules/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/modules/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/modules/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/modules/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) { From 0e743103fc284dfb2f6a8811ebcd107a9d9826d0 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Fri, 13 Feb 2026 15:37:46 +0100 Subject: [PATCH 06/27] feat(docs): expand normalized data model with new modules and enhanced field mappings - Added documentation for Carts, Customers, and Checkout services, including API methods and data models. - Updated Products module to reference associated entities (e.g., Carts and Orders). - Enhanced Tickets API docs with clarified field mapping and integration-specific requirements. - Improved docs with consistent formatting. --- apps/docs/blog/releases/o2s/1.5.0.md | 4 +- .../articles/zendesk/how-to-setup.md | 8 +- .../commerce/medusa-js/cart-checkout.md | 79 +++++ .../commerce/medusa-js/features.md | 32 ++ .../commerce/medusa-js/overview.md | 65 +++- .../integrations/tickets/zendesk/features.md | 119 +++---- .../tickets/zendesk/how-to-setup.md | 6 +- .../normalized-data-model/core-model-carts.md | 323 ++++++++++++++++++ .../core-model-checkout.md | 217 ++++++++++++ .../core-model-customers.md | 153 +++++++++ .../core-model-orders.md | 216 ++++++++++++ .../core-model-products.md | 2 +- .../core-model-services.md | 200 ++++++++++- .../core-model-tickets.md | 15 +- .../normalized-data-model/overview.md | 13 +- 15 files changed, 1360 insertions(+), 92 deletions(-) create mode 100644 apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md create mode 100644 apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-carts.md create mode 100644 apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-checkout.md create mode 100644 apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-customers.md create mode 100644 apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-orders.md 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/commerce/medusa-js/cart-checkout.md b/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md new file mode 100644 index 000000000..f0ede59ac --- /dev/null +++ b/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md @@ -0,0 +1,79 @@ +--- +sidebar_position: 300 +--- + +# Cart & Checkout + +This document describes the cart lifecycle and checkout process when using the Medusa.js integration. + +## Cart Lifecycle + +The typical flow is: + +1. **Create cart** — `POST /carts` with `currency` (and `region_id` for Medusa) +2. **Add items** — `POST /carts/items` with `variantId`, `quantity`, optional `cartId` +3. **Update addresses** — via checkout: `POST /checkout/:cartId/addresses` +4. **Add shipping method** — `POST /checkout/:cartId/shipping-method` +5. **Set payment** — `POST /checkout/:cartId/payment` +6. **Place order** — `POST /checkout/:cartId/place-order` + +The frontend orchestrates these steps. The API does not track checkout state; each action is independent. + +## Checkout API Endpoints + +| Method | Route | 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 (cart + totals + addresses) | +| POST | `/checkout/:cartId/place-order` | Create order from cart | +| POST | `/checkout/:cartId/complete` | One-shot complete flow (addresses → shipping → payment → order) | + +## Medusa-Specific Behavior + +### Cart Creation + +- **`region_id`** — Required. Medusa associates carts with regions (pricing, shipping, taxes). +- **`currency_code`** — Required. Use `DEFAULT_CURRENCY` when not provided. + +### Adding Items + +- **`variantId` required** — Medusa uses product variants, not productId alone. Use the variant ID from the product catalog. +- When `cartId` is omitted and no active cart exists, a new cart is created via `sdk.store.cart.create` (or `createCartAndAddItem`). + +### Shipping Options + +- Fetched from `sdk.store.fulfillment.listCartOptions({ cart_id })`. +- For options with `price_type: calculated`, prices are computed via `sdk.store.fulfillment.calculate`. + +### Order Placement + +- Uses `sdk.store.cart.complete(cartId)` to convert the cart into an order in Medusa. +- Returns the created order; no separate order creation endpoint is used. + +### Guest Checkout + +- Supported. No authentication required. +- **Email** — Required for guest order placement (passed in `placeOrder` or `completeCheckout` body). +- Addresses must be provided inline; saved-address IDs are for authenticated users only. + +### Limitations + +| Capability | MedusaJS | Notes | +| ---------------- | -------- | ---------------------------------------- | +| `getCartList` | No | Store API does not support listing carts | +| `getCurrentCart` | No | No way to query carts by customer | +| `deleteCart` | No-op | Store API has no cart delete endpoint | + +## Dependencies + +Checkout delegates to: + +- **Carts** — `updateCartAddresses`, `addShippingMethod` +- **Customers** — Resolve `shippingAddressId` / `billingAddressId` for authenticated users +- **Payments** — `createSession` for payment setup +- **Medusa SDK** — Fulfillment options, `cart.complete` + +Ensure Carts, Checkout, Customers, and Payments are configured with Medusa.js (see [Configuration](./overview.md#step-1-update-the-integration-configs)). diff --git a/apps/docs/docs/integrations/commerce/medusa-js/features.md b/apps/docs/docs/integrations/commerce/medusa-js/features.md index 13a688b56..480a839cb 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/features.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/features.md @@ -10,6 +10,8 @@ This document provides an overview of features supported by the Medusa.js integr | 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 Recommendations](#product-recommendations) | ✅ | Related products ([requires plugin](#plugin-architecture)) | @@ -25,6 +27,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: diff --git a/apps/docs/docs/integrations/commerce/medusa-js/overview.md b/apps/docs/docs/integrations/commerce/medusa-js/overview.md index 6b0a8d684..9f37772ae 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/overview.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/overview.md @@ -9,6 +9,7 @@ This integration provides a full integration with [Medusa.js](https://medusajs.c ## In this section - [Features](./features.md) - Overview of features supported by the Medusa.js integration +- [Cart & Checkout](./cart-checkout.md) - Cart lifecycle and checkout process ## Installation @@ -54,6 +55,48 @@ 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/resources.ts` (if using Resources module):** ```typescript @@ -93,13 +136,17 @@ For more details about authentication setup, see the official [Medusa.js SDK doc ## Supported modules -The integration implements three core modules from the O2S framework: +The integration implements these modules from the O2S framework: -| Module | Description | Plugin Required | -| --------- | --------------------------------------- | --------------- | -| Orders | Order management and history | No | -| Products | Product catalog and variants | No | -| Resources | Assets and service instances management | Yes | +| 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 | ## Dependencies @@ -121,10 +168,10 @@ The integration uses the official Medusa.js SDK for most operations, combined wi └─────────────────┘ └──────────────────┘ └─────────────────┘ │ │ │ Medusa.js SDK │ Admin API - │ HTTP Client │ Store API + │ Store API │ Store API ▼ ▼ ┌──────────────────┐ ┌─────────────────┐ - │ Orders/Products │ │ Assets/Services │ - │ (native) │ │ (plugin) │ + │ Carts/Checkout/ │ │ Assets/Services │ + │ Orders/Products │ │ (plugin) │ └──────────────────┘ └─────────────────┘ ``` 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/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..abacaa7c6 --- /dev/null +++ b/apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-carts.md @@ -0,0 +1,323 @@ +--- +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) | +| productId | string | Product ID (required) | +| variantId | string | Variant ID (required by some backends e.g. Medusa) | +| quantity | number | Quantity (required) | +| currency | Currency | Required when creating new cart | +| regionId | string | Required when creating new cart (e.g. Medusa) | + +### 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 | +| productId | string | Product ID | +| variantId | string | Variant ID (optional) | +| 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 | +| type | PaymentMethodType | Payment method type | +| description | string | Description (optional) | + +### Promotion + +| Field | Type | Description | +| --------- | -------------- | ----------------------------------- | +| id | string | Unique identifier | +| code | string | Promotion code | +| name | string | Display name | +| type | PromotionType | Percentage, fixed, or free shipping | +| value | number | Discount value | +| appliedTo | PromotionScope | CART, ITEM, or SHIPPING | + +### CartType + +| Value | Description | +| --------- | -------------------- | +| ACTIVE | Active shopping cart | +| SAVED | Saved for later | +| ABANDONED | Abandoned cart | + +### PaymentMethodType + +| Value | Description | +| ------------- | ------------- | +| CREDIT_CARD | Credit card | +| PAYPAL | PayPal | +| BANK_TRANSFER | Bank transfer | +| OTHER | Other methods | + +### PromotionType / PromotionScope + +| PromotionType | Description | +| ------------- | --------------------- | +| PERCENTAGE | Percentage discount | +| FIXED_AMOUNT | Fixed amount discount | +| FREE_SHIPPING | Free shipping | + +| PromotionScope | Description | +| -------------- | ---------------------- | +| CART | Applied to entire cart | +| ITEM | Applied to item | +| SHIPPING | Applied to 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-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..55f0d1b29 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,15 @@ 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 +- [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. From b2c0d84d28c8c6c314930882e86f2aab1e8a2d65 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 16 Feb 2026 12:50:40 +0100 Subject: [PATCH 07/27] refactor: improve Medusa.js cart and checkout module handling - Replaced hardcoded URLs with dynamic inputs for better flexibility in checkout. - Enhanced promotion handling with direct API invocation for addition and removal. - Refactored `carts.mapper` to standardize currency to uppercase. --- .../src/modules/carts/carts.controller.ts | 2 +- .../src/modules/carts/carts.request.ts | 2 +- .../src/modules/carts/carts.service.ts | 82 +++++++++--------- .../src/modules/checkout/checkout.request.ts | 4 + .../src/modules/users/users.controller.ts | 10 --- .../integrations/medusajs/src/integration.ts | 2 +- .../src/modules/carts/carts.mapper.spec.ts | 2 +- .../src/modules/carts/carts.mapper.ts | 2 +- .../src/modules/carts/carts.service.spec.ts | 45 +++++----- .../src/modules/carts/carts.service.ts | 83 ++++++------------ .../modules/checkout/checkout.service.spec.ts | 7 +- .../src/modules/checkout/checkout.service.ts | 14 ++-- .../src/modules/medusajs/medusajs.service.ts | 9 +- .../modules/payments/payments.service.spec.ts | 23 ++--- .../src/modules/payments/payments.service.ts | 84 +++++++++---------- .../src/modules/products/products.service.ts | 6 +- .../mocked/src/modules/carts/carts.mapper.ts | 2 +- .../src/modules/checkout/checkout.service.ts | 6 +- 18 files changed, 169 insertions(+), 216 deletions(-) diff --git a/packages/framework/src/modules/carts/carts.controller.ts b/packages/framework/src/modules/carts/carts.controller.ts index 7116b98b7..de6342280 100644 --- a/packages/framework/src/modules/carts/carts.controller.ts +++ b/packages/framework/src/modules/carts/carts.controller.ts @@ -75,7 +75,7 @@ export class CartsController { return this.cartService.applyPromotion(params, body, headers.authorization); } - @Delete(':cartId/promotions/:promotionId') + @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.request.ts b/packages/framework/src/modules/carts/carts.request.ts index 4215d5d92..29bb99cdd 100644 --- a/packages/framework/src/modules/carts/carts.request.ts +++ b/packages/framework/src/modules/carts/carts.request.ts @@ -83,7 +83,7 @@ export class ApplyPromotionBody { export class RemovePromotionParams { cartId!: string; - promotionId!: string; + code!: string; } // Checkout operations diff --git a/packages/framework/src/modules/carts/carts.service.ts b/packages/framework/src/modules/carts/carts.service.ts index 18fa346d7..1604efe76 100644 --- a/packages/framework/src/modules/carts/carts.service.ts +++ b/packages/framework/src/modules/carts/carts.service.ts @@ -1,69 +1,73 @@ import { Observable } from 'rxjs'; -import * as Carts from './'; +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: Carts.Request.GetCartParams, - authorization?: string, - ): Observable; + abstract getCart(params: GetCartParams, authorization?: string): Observable; - abstract getCartList(query: Carts.Request.GetCartListQuery, authorization?: string): Observable; + abstract getCartList(query: GetCartListQuery, authorization?: string): Observable; - abstract createCart(data: Carts.Request.CreateCartBody, authorization?: string): Observable; + abstract createCart(data: CreateCartBody, authorization?: string): Observable; - abstract updateCart( - params: Carts.Request.UpdateCartParams, - data: Carts.Request.UpdateCartBody, - authorization?: string, - ): Observable; + abstract updateCart(params: UpdateCartParams, data: UpdateCartBody, authorization?: string): Observable; - abstract deleteCart(params: Carts.Request.DeleteCartParams, authorization?: string): Observable; + abstract deleteCart(params: DeleteCartParams, authorization?: string): Observable; - abstract addCartItem(data: Carts.Request.AddCartItemBody, authorization?: string): Observable; + abstract addCartItem(data: AddCartItemBody, authorization?: string): Observable; abstract updateCartItem( - params: Carts.Request.UpdateCartItemParams, - data: Carts.Request.UpdateCartItemBody, + params: UpdateCartItemParams, + data: UpdateCartItemBody, authorization?: string, - ): Observable; + ): Observable; - abstract removeCartItem( - params: Carts.Request.RemoveCartItemParams, - authorization?: string, - ): Observable; + abstract removeCartItem(params: RemoveCartItemParams, authorization?: string): Observable; abstract applyPromotion( - params: Carts.Request.ApplyPromotionParams, - data: Carts.Request.ApplyPromotionBody, + params: ApplyPromotionParams, + data: ApplyPromotionBody, authorization?: string, - ): Observable; + ): Observable; - abstract removePromotion( - params: Carts.Request.RemovePromotionParams, - authorization?: string, - ): Observable; + abstract removePromotion(params: RemovePromotionParams, authorization?: string): Observable; - abstract getCurrentCart(authorization?: string): Observable; + abstract getCurrentCart(authorization?: string): Observable; - abstract prepareCheckout( - params: Carts.Request.PrepareCheckoutParams, - authorization?: string, - ): Observable; + abstract prepareCheckout(params: PrepareCheckoutParams, authorization?: string): Observable; // Update cart addresses (shipping and/or billing) abstract updateCartAddresses( - params: Carts.Request.UpdateCartAddressesParams, - data: Carts.Request.UpdateCartAddressesBody, + params: UpdateCartAddressesParams, + data: UpdateCartAddressesBody, authorization?: string, - ): Observable; + ): Observable; // Add shipping method to cart abstract addShippingMethod( - params: Carts.Request.AddShippingMethodParams, - data: Carts.Request.AddShippingMethodBody, + params: AddShippingMethodParams, + data: AddShippingMethodBody, authorization?: string, - ): Observable; + ): Observable; } diff --git a/packages/framework/src/modules/checkout/checkout.request.ts b/packages/framework/src/modules/checkout/checkout.request.ts index b6a768ed0..3f19fad44 100644 --- a/packages/framework/src/modules/checkout/checkout.request.ts +++ b/packages/framework/src/modules/checkout/checkout.request.ts @@ -27,6 +27,8 @@ export class SetPaymentParams { export class SetPaymentBody { providerId!: string; + returnUrl!: string; + cancelUrl?: string; metadata?: Record; } @@ -61,6 +63,8 @@ export class CompleteCheckoutBody { 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/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/integrations/medusajs/src/integration.ts b/packages/integrations/medusajs/src/integration.ts index f68b441eb..1cd379f99 100644 --- a/packages/integrations/medusajs/src/integration.ts +++ b/packages/integrations/medusajs/src/integration.ts @@ -42,7 +42,7 @@ export const Config: Partial = { payments: { name: 'medusajs', service: PaymentsService, - imports: [MedusaJsModule], + imports: [MedusaJsModule, Auth.Module], }, checkout: { name: 'medusajs', diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts index 37479ec98..391e4ea6f 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts @@ -32,7 +32,7 @@ describe('carts.mapper', () => { const result = mapCart(cart, defaultCurrency); expect(result.id).toBe('cart_1'); expect(result.customerId).toBeUndefined(); - expect(result.currency).toBe('eur'); + expect(result.currency).toBe('EUR'); expect(result.items.data).toHaveLength(0); expect(result.items.total).toBe(0); expect(result.subtotal?.value).toBe(9000); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index 033591b33..ad66c2b7b 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -16,7 +16,7 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca if (!cart.currency_code) { throw new Error(`Cart ${cart.id} has no currency code`); } - const currency = cart.currency_code as Models.Price.Currency; + const currency = cart.currency_code.toUpperCase() as Models.Price.Currency; return { id: cart.id, diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts index 9f91c71c5..e0beb7da1 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -43,6 +43,7 @@ describe('CartsService', () => { addShippingMethod: ReturnType; }; }; + client: { fetch: ReturnType }; }; let mockMedusaJsService: { getSdk: ReturnType; getStoreApiHeaders: ReturnType }; let mockAuthService: { getCustomerId: ReturnType }; @@ -64,6 +65,7 @@ describe('CartsService', () => { addShippingMethod: vi.fn(), }, }, + client: { fetch: vi.fn() }, }; mockMedusaJsService = { getSdk: vi.fn(() => mockSdk), @@ -111,7 +113,7 @@ describe('CartsService', () => { expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith('cart_1', {}, expect.any(Object)); expect(result).toBeDefined(); expect(result?.id).toBe('cart_1'); - expect(result?.currency).toBe('eur'); + expect(result?.currency).toBe('EUR'); }); it('should throw UnauthorizedException when cart.customerId !== auth customerId', async () => { @@ -414,8 +416,8 @@ describe('CartsService', () => { }); describe('applyPromotion', () => { - it('should call cart.update with promo_codes', async () => { - mockSdk.store.cart.update.mockResolvedValue({ cart: minimalCart }); + it('should call client.fetch with promo_codes', async () => { + mockSdk.client.fetch.mockResolvedValue({ cart: minimalCart }); const result = await firstValueFrom( service.applyPromotion( @@ -425,42 +427,37 @@ describe('CartsService', () => { ), ); - expect(mockSdk.store.cart.update).toHaveBeenCalledWith( - 'cart_1', - { promo_codes: ['SAVE10'] }, - {}, - expect.any(Object), + 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 retrieve cart, filter promotion, update with remaining codes', async () => { - const cartWithPromos = { - ...minimalCart, - promotions: [ - { id: 'promo_1', code: 'SAVE10' }, - { id: 'promo_2', code: 'FREE_SHIP' }, - ], - }; - mockSdk.store.cart.retrieve.mockResolvedValue({ cart: cartWithPromos }); - mockSdk.store.cart.update.mockResolvedValue({ cart: minimalCart }); + 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', promotionId: 'promo_1' } as Carts.Request.RemovePromotionParams, + { cartId: 'cart_1', code: 'SAVE10' } as Carts.Request.RemovePromotionParams, 'Bearer token', ), ); - expect(mockSdk.store.cart.update).toHaveBeenCalledWith( - 'cart_1', - { promo_codes: ['FREE_SHIP'] }, - {}, - expect.any(Object), + expect(mockSdk.client.fetch).toHaveBeenCalledWith( + '/store/carts/cart_1/promotions', + expect.objectContaining({ + method: 'POST', + body: expect.objectContaining({ promo_codes: ['SAVE10'] }), + }), ); expect(result).toBeDefined(); + expect(result?.id).toBe('cart_1'); }); }); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 917eff2ae..2e7ccbfc9 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -165,7 +165,7 @@ export class CartsService extends Carts.Service { this.sdk.store.cart.createLineItem( cartId, { - variant_id: data.variantId!, + variant_id: data.variantId, quantity: data.quantity, metadata: data.metadata, }, @@ -184,24 +184,9 @@ export class CartsService extends Carts.Service { throw new BadRequestException('Currency is required when creating a new cart'); } - // For authenticated users, create a new cart (can't query existing carts) - // Medusa doesn't provide a way to query carts by customer - // Store API doesn't support listing carts - // Admin API doesn't have /admin/carts endpoint - if (customerId) { - return this.createCartAndAddItem( - data.currency!, - data.variantId!, - data.quantity, - data.regionId, - data.metadata, - ); - } - - // For guests, create a new cart return this.createCartAndAddItem( data.currency, - data.variantId!, + data.variantId, data.quantity, data.regionId, data.metadata, @@ -212,7 +197,7 @@ export class CartsService extends Carts.Service { updateCartItem( params: Carts.Request.UpdateCartItemParams, data: Carts.Request.UpdateCartItemBody, - authorization: string | undefined, + authorization?: string, ): Observable { return from( this.sdk.store.cart.updateLineItem( @@ -252,55 +237,44 @@ export class CartsService extends Carts.Service { applyPromotion( params: Carts.Request.ApplyPromotionParams, data: Carts.Request.ApplyPromotionBody, - authorization: string | undefined, + authorization?: string, ): Observable { return from( - this.sdk.store.cart.update( - params.cartId, - { + 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], }, - {}, - this.medusaJsService.getStoreApiHeaders(authorization), - ), + }), ).pipe( - map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), + map((response) => mapCart(response.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); } removePromotion(params: Carts.Request.RemovePromotionParams, authorization?: string): Observable { - // In Medusa v2, removing promotions requires updating the cart - // with the remaining promo codes (excluding the one to remove) return from( - this.sdk.store.cart.retrieve(params.cartId, {}, this.medusaJsService.getStoreApiHeaders(authorization)), - ).pipe( - switchMap((response: HttpTypes.StoreCartResponse) => { - const cart = response.cart; - // Filter out the promotion to remove - const remainingCodes = - cart.promotions - ?.filter((promo: { id?: string }) => promo.id !== params.promotionId) - .map((promo: { code?: string }) => promo.code) - .filter((code: string | undefined): code is string => code !== undefined) ?? []; - - return from( - this.sdk.store.cart.update( - params.cartId, - { - promo_codes: remainingCodes, - }, - {}, - this.medusaJsService.getStoreApiHeaders(authorization), - ), - ); + this.sdk.client.fetch(`/store/carts/${params.cartId}/promotions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.medusaJsService.getStoreApiHeaders(authorization), + }, + body: { + promo_codes: [params.code], + }, }), - map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), + ).pipe( + map((response) => mapCart(response.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); } - getCurrentCart(_authorization: string | undefined): Observable { + getCurrentCart(_authorization?: string): Observable { return throwError( () => new NotImplementedException( @@ -520,7 +494,7 @@ export class CartsService extends Carts.Service { */ private resolveBillingAddress( data: Carts.Request.UpdateCartAddressesBody, - authorization: string | undefined, + authorization?: string, ): Observable { if (data.billingAddressId && authorization) { return this.customersService.getAddress({ id: data.billingAddressId }, authorization).pipe( @@ -545,10 +519,7 @@ export class CartsService extends Carts.Service { * into existing metadata without mutating any arguments. * Email is passed directly on the cart (not in metadata). */ - private buildCartMetadata( - notes: string | undefined, - existingMetadata?: Record, - ): Record { + private buildCartMetadata(notes?: string, existingMetadata?: Record): Record { const metadata: Record = { ...(existingMetadata || {}) }; if (notes !== undefined) { metadata.notes = notes; diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts index 2c6e4dcee..afc1384a1 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.spec.ts @@ -1,10 +1,9 @@ -import { BadRequestException } from '@nestjs/common'; -import { NotFoundException } from '@nestjs/common'; +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 { Auth, Carts, Checkout, Customers, Payments } from '@o2s/framework/modules'; +import { Carts, Checkout, Payments } from '@o2s/framework/modules'; import { CheckoutService } from './checkout.service'; @@ -88,9 +87,7 @@ describe('CheckoutService', () => { mockLogger as unknown as import('@o2s/utils.logger').LoggerService, mockConfig as unknown as ConfigService, mockMedusaJsService as unknown as import('@/modules/medusajs').Service, - {} as unknown as Auth.Service, mockCartsService as unknown as Carts.Service, - {} as unknown as Customers.Service, mockPaymentsService as unknown as Payments.Service, ); }); diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts index 2ae5eafc7..bb46e2e3d 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts @@ -6,7 +6,7 @@ import { Observable, catchError, forkJoin, from, map, of, switchMap, throwError import { LoggerService } from '@o2s/utils.logger'; -import { Auth, Carts, Checkout, Customers, Payments } from '@o2s/framework/modules'; +import { Carts, Checkout, Payments } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; @@ -24,9 +24,7 @@ export class CheckoutService extends Checkout.Service { @Inject(LoggerService) protected readonly logger: LoggerService, private readonly config: ConfigService, private readonly medusaJsService: MedusaJsService, - private readonly authService: Auth.Service, private readonly cartsService: Carts.Service, - private readonly customersService: Customers.Service, private readonly paymentsService: Payments.Service, ) { super(); @@ -96,8 +94,8 @@ export class CheckoutService extends Checkout.Service { { cartId: params.cartId, providerId: data.providerId, - returnUrl: 'https://example.com/checkout/return', - cancelUrl: 'https://example.com/checkout/cancel', + returnUrl: data.returnUrl, + cancelUrl: data.cancelUrl, metadata: data.metadata, }, authorization, @@ -241,9 +239,7 @@ export class CheckoutService extends Checkout.Service { params: Checkout.Request.GetShippingOptionsParams, authorization?: string, ): Observable { - const headers = authorization - ? this.medusaJsService.getStoreApiHeaders(authorization) - : this.medusaJsService.getStoreApiHeaders(authorization); + const headers = this.medusaJsService.getStoreApiHeaders(authorization); // Step 1: Retrieve shipping options return from(this.sdk.store.fulfillment.listCartOptions({ cart_id: params.cartId }, headers)).pipe( @@ -329,6 +325,8 @@ export class CheckoutService extends Checkout.Service { { cartId: params.cartId }, { providerId: data.paymentProviderId, + returnUrl: data.returnUrl, + cancelUrl: data.cancelUrl, metadata: data.metadata, }, authorization, diff --git a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts index 74659722c..334249e68 100644 --- a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts +++ b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts @@ -67,10 +67,9 @@ export class MedusaJsService { this._sdk = new Medusa({ baseUrl: this._medusaBaseUrl, - // debug: this.logLevel === 'debug', - debug: true, + debug: this.logLevel === 'debug', publishableKey: this._medusaPublishableApiKey, - // apiKey: this._medusaAdminApiKey, + apiKey: this._medusaAdminApiKey, auth: { type: 'jwt', }, @@ -124,7 +123,9 @@ export class MedusaJsService { * @param authorization - Authorization header value from the API Harmonization layer (SSO JWT) */ getStoreApiHeaders(authorization?: string): Record { - const headers: Record = {}; + const headers: Record = { + 'x-publishable-api-key': this.getPublishableKey(), + }; if (authorization) { headers['Authorization'] = authorization; } diff --git a/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts index 26863ac5a..d4bba3055 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts @@ -1,6 +1,6 @@ import { HttpTypes } from '@medusajs/types'; import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, of, throwError } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Payments } from '@o2s/framework/modules'; @@ -210,12 +210,9 @@ describe('PaymentsService', () => { describe('updateSession', () => { it('should post to payment-sessions and return mapped session', async () => { - mockHttpService.post.mockReturnValue({ - toPromise: () => - Promise.resolve({ - data: { payment_session: { ...minimalPaymentSession, id: 'ps_updated' } }, - }), - }); + mockHttpService.post.mockReturnValue( + of({ data: { payment_session: { ...minimalPaymentSession, id: 'ps_updated' } } }), + ); const result = await firstValueFrom( service.updateSession( @@ -234,9 +231,7 @@ describe('PaymentsService', () => { }); it('should throw NotFoundException on 404', async () => { - mockHttpService.post.mockReturnValue({ - toPromise: () => Promise.reject({ response: { status: 404 } }), - }); + mockHttpService.post.mockReturnValue(throwError(() => ({ response: { status: 404 } }))); await expect( firstValueFrom( @@ -252,9 +247,7 @@ describe('PaymentsService', () => { describe('cancelSession', () => { it('should delete payment-sessions and return void', async () => { - mockHttpService.delete.mockReturnValue({ - toPromise: () => Promise.resolve({}), - }); + mockHttpService.delete.mockReturnValue(of({})); await firstValueFrom( service.cancelSession({ id: 'ps_1' } as Payments.Request.CancelSessionParams, 'Bearer token'), @@ -267,9 +260,7 @@ describe('PaymentsService', () => { }); it('should throw NotFoundException on 404', async () => { - mockHttpService.delete.mockReturnValue({ - toPromise: () => Promise.reject({ response: { status: 404 } }), - }); + mockHttpService.delete.mockReturnValue(throwError(() => ({ response: { status: 404 } }))); await expect( firstValueFrom( diff --git a/packages/integrations/medusajs/src/modules/payments/payments.service.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.ts index eb573c1d7..4f3631f16 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.service.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.ts @@ -151,33 +151,33 @@ export class PaymentsService extends Payments.Service { updatePayload['metadata'] = data.metadata; } - return from( - this.httpClient - .post<{ payment_session: HttpTypes.StorePaymentSession }>( - `${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, - updatePayload, - { - headers: this.medusaJsService.getStoreApiHeaders(authorization), - }, - ) - .toPromise(), - ).pipe( - map((response) => { - if (!response?.data) { - throw new Error('Failed to update payment session'); - } - // We don't have cart ID here, so we'll use empty string - // In practice, this should be retrieved from the session or stored context - const cartId = ''; - return mapPaymentSession(response.data.payment_session, cartId); - }), - catchError((error) => { - if (error.response?.status === 404) { - return throwError(() => new NotFoundException(`Payment session with ID ${params.id} not found`)); - } - return handleHttpError(error); - }), - ); + return this.httpClient + .post<{ payment_session: HttpTypes.StorePaymentSession }>( + `${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, + updatePayload, + { + headers: this.medusaJsService.getStoreApiHeaders(authorization), + }, + ) + .pipe( + map((response) => { + if (!response?.data) { + throw new Error('Failed to update payment session'); + } + // We don't have cart ID here, so we'll use empty string + // In practice, this should be retrieved from the session or stored context + const cartId = ''; + return mapPaymentSession(response.data.payment_session, cartId); + }), + catchError((error) => { + if (error.response?.status === 404) { + return throwError( + () => new NotFoundException(`Payment session with ID ${params.id} not found`), + ); + } + return handleHttpError(error); + }), + ); } /** @@ -190,20 +190,20 @@ export class PaymentsService extends Payments.Service { * @see {@link https://docs.medusajs.com/api/store#payment-sessions_deletepayment-sessionsid Medusa Store API - Delete Payment Session} */ cancelSession(params: Payments.Request.CancelSessionParams, authorization: string | undefined): Observable { - return from( - this.httpClient - .delete(`${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, { - headers: this.medusaJsService.getStoreApiHeaders(authorization), - }) - .toPromise(), - ).pipe( - map(() => undefined), - catchError((error) => { - if (error.response?.status === 404) { - return throwError(() => new NotFoundException(`Payment session with ID ${params.id} not found`)); - } - return handleHttpError(error); - }), - ); + return this.httpClient + .delete(`${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, { + headers: this.medusaJsService.getStoreApiHeaders(authorization), + }) + .pipe( + map(() => undefined), + catchError((error) => { + if (error.response?.status === 404) { + return throwError( + () => new NotFoundException(`Payment session with ID ${params.id} not found`), + ); + } + return handleHttpError(error); + }), + ); } } diff --git a/packages/integrations/medusajs/src/modules/products/products.service.ts b/packages/integrations/medusajs/src/modules/products/products.service.ts index 323635d9a..83e58095a 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.ts @@ -1,7 +1,7 @@ import Medusa from '@medusajs/js-sdk'; import { HttpTypes } from '@medusajs/types'; import { HttpService } from '@nestjs/axios'; -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Observable, catchError, from, map } from 'rxjs'; @@ -79,7 +79,7 @@ export class ProductsService extends Products.Service { map((response: HttpTypes.StoreProductResponse) => { const product = response.product; if (!product?.variants?.length) { - throw new Error(`No variants found for product ${params.id}`); + throw new NotFoundException(`No variants found for product ${params.id}`); } // Find the requested variant, or use the first one @@ -87,7 +87,7 @@ export class ProductsService extends Products.Service { const variant = params.variantId ? variants.find((v) => v.id === params.variantId) : variants[0]; if (!variant) { - throw new Error(`Variant ${params.variantId} not found for product ${params.id}`); + throw new NotFoundException(`Variant ${params.variantId} not found for product ${params.id}`); } // Ensure the variant has a reference to the product for mapping diff --git a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts index 300e5c869..9d1a1c572 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -374,7 +374,7 @@ export const removePromotion = (params: Carts.Request.RemovePromotionParams): Ca const cart = cartsStore[cartIndex]!; if (!cart.promotions) return cart; - const promoIndex = cart.promotions.findIndex((p) => p.id === params.promotionId); + const promoIndex = cart.promotions.findIndex((p) => p.code === params.code); if (promoIndex === -1) return cart; cart.promotions.splice(promoIndex, 1); diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts index bc62ee3eb..941a9bdd5 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { Observable, of, throwError } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; -import { Auth, Carts, Checkout, Customers, Payments } from '@o2s/framework/modules'; +import { Carts, Checkout, Payments } from '@o2s/framework/modules'; import { MOCKED_ORDERS, mapOrderFromCart } from '../orders/orders.mapper'; @@ -12,9 +12,7 @@ import { responseDelay } from '@/utils/delay'; @Injectable() export class CheckoutService implements Checkout.Service { constructor( - private readonly authService: Auth.Service, private readonly cartsService: Carts.Service, - private readonly customersService: Customers.Service, private readonly paymentsService: Payments.Service, ) {} @@ -224,6 +222,8 @@ export class CheckoutService implements Checkout.Service { { cartId: params.cartId }, { providerId: data.paymentProviderId, + returnUrl: data.returnUrl, + cancelUrl: data.cancelUrl, metadata: data.metadata, }, authorization, From 00046321e3f42af6e2a47af12172e2c7383c8001 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 16 Feb 2026 13:06:25 +0100 Subject: [PATCH 08/27] chore(ci): added changesets --- .changeset/easy-sides-enjoy.md | 5 +++++ .changeset/lucky-sides-battle.md | 9 +++++++++ .changeset/silent-bees-play.md | 5 +++++ 3 files changed, 19 insertions(+) create mode 100644 .changeset/easy-sides-enjoy.md create mode 100644 .changeset/lucky-sides-battle.md create mode 100644 .changeset/silent-bees-play.md 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/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/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. From 27fdc94d3b2323121fb5e8caddc37153f103b80c Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 16 Feb 2026 13:08:34 +0100 Subject: [PATCH 09/27] refactor: rolled back `customerId` the the one used previously --- packages/integrations/mocked/prisma/seed.ts | 2 +- .../integrations/mocked/src/modules/orders/orders.mapper.ts | 2 +- .../mocked/src/modules/organizations/organizations.mapper.ts | 2 +- .../mocked/src/modules/resources/resources.mapper.ts | 4 ++-- .../integrations/mocked/src/modules/users/customers.mapper.ts | 2 +- .../integrations/mocked/src/modules/users/users.mapper.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/integrations/mocked/prisma/seed.ts b/packages/integrations/mocked/prisma/seed.ts index 177597239..66dd7980c 100644 --- a/packages/integrations/mocked/prisma/seed.ts +++ b/packages/integrations/mocked/prisma/seed.ts @@ -15,7 +15,7 @@ async function main() { name: 'Jane Doe', email: 'jane@example.com', password: await hash('admin', 10), - defaultCustomerId: 'cus_01KH3J08TY40PYGVEG3A04CP8R', // Acme Corp - full admin permissions + defaultCustomerId: 'cust-001', // Acme Corp - full admin permissions }, { id: 'user-100', diff --git a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts index ab07dcc46..98bdd0d5d 100644 --- a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts @@ -142,7 +142,7 @@ const DOCUMENT_DATA: Orders.Model.Document[] = [ ]; // Customer IDs -const CUSTOMER_IDS = ['cus_01KH3J08TY40PYGVEG3A04CP8R']; +const CUSTOMER_IDS = ['cust-001']; // Shipping methods const SHIPPING_METHODS = [ diff --git a/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts b/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts index b6a3d67d8..e47da557d 100644 --- a/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts +++ b/packages/integrations/mocked/src/modules/organizations/organizations.mapper.ts @@ -66,7 +66,7 @@ const MOCK_ORGANIZATION_1: Organizations.Model.Organization = { }, isActive: true, children: [MOCK_ORGANIZATION_2, MOCK_ORGANIZATION_3], - customers: [mapCustomer('cus_01KH3J08TY40PYGVEG3A04CP8R')!], + customers: [mapCustomer('cust-001')!], }; const MOCK_ORGANIZATIONS = [MOCK_ORGANIZATION_1, MOCK_ORGANIZATION_2, MOCK_ORGANIZATION_3]; diff --git a/packages/integrations/mocked/src/modules/resources/resources.mapper.ts b/packages/integrations/mocked/src/modules/resources/resources.mapper.ts index 8b2eb5d86..869e2182a 100644 --- a/packages/integrations/mocked/src/modules/resources/resources.mapper.ts +++ b/packages/integrations/mocked/src/modules/resources/resources.mapper.ts @@ -395,7 +395,7 @@ export const mapAssets = ( let assets: Resources.Model.Asset[] = []; switch (customerId) { - case 'cus_01KH3J08TY40PYGVEG3A04CP8R': + case 'cust-001': assets = MOCK_ASSETS_FOR_CUSTOMER_1; break; case 'cust-002': @@ -474,7 +474,7 @@ export const mapServices = ( let services = MOCK_SERVICES_DEFAULT; switch (customerId) { - case 'cus_01KH3J08TY40PYGVEG3A04CP8R': + case 'cust-001': services = MOCK_SERVICES_FOR_CUSTOMER_1; break; case 'cust-002': diff --git a/packages/integrations/mocked/src/modules/users/customers.mapper.ts b/packages/integrations/mocked/src/modules/users/customers.mapper.ts index 9c6d01f95..e264109a5 100644 --- a/packages/integrations/mocked/src/modules/users/customers.mapper.ts +++ b/packages/integrations/mocked/src/modules/users/customers.mapper.ts @@ -32,7 +32,7 @@ const PROSPECT_PERMISSIONS: Auth.Model.Permissions = { }; const MOCK_CUSTOMER_1: Models.Customer.Customer = { - id: 'cus_01KH3J08TY40PYGVEG3A04CP8R', + id: 'cust-001', name: 'Acme Corporation', clientType: 'B2B', address: { diff --git a/packages/integrations/mocked/src/modules/users/users.mapper.ts b/packages/integrations/mocked/src/modules/users/users.mapper.ts index 5df20fb61..6e614bc83 100644 --- a/packages/integrations/mocked/src/modules/users/users.mapper.ts +++ b/packages/integrations/mocked/src/modules/users/users.mapper.ts @@ -23,7 +23,7 @@ const MOCK_USER_2: Users.Model.User = { firstName: 'Jane', lastName: 'Doe', customers: [ - mapCustomer('cus_01KH3J08TY40PYGVEG3A04CP8R')!, // Acme Corp - admin permissions (full access) + mapCustomer('cust-001')!, // Acme Corp - admin permissions (full access) mapCustomer('cust-002')!, // Tech Solutions - user permissions (view + pay) ], }; From c556cdab1da736d7edb33ff7aba4bcbb2a77cf9f Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 16 Feb 2026 14:36:01 +0100 Subject: [PATCH 10/27] refactor: replace `productId` and `variantId` with `sku` across cart module - Updated models, mappers, and service logic to use `sku` for identifying product variants. - Adjusted tests and mocked data mappings to align with the new `sku` field. - Revised documentation to reflect the parameter changes and improve consistency. --- .../normalized-data-model/core-model-carts.md | 18 +++++++-------- .../src/modules/carts/carts.model.ts | 3 +-- .../src/modules/carts/carts.request.ts | 3 +-- .../src/modules/carts/carts.mapper.spec.ts | 5 ++--- .../src/modules/carts/carts.mapper.ts | 3 +-- .../src/modules/carts/carts.service.spec.ts | 17 ++++++-------- .../src/modules/carts/carts.service.ts | 17 ++++++-------- .../mocked/src/modules/carts/carts.mapper.ts | 22 +++++-------------- .../src/modules/orders/orders.mapper.ts | 2 +- .../src/modules/products/products.mapper.ts | 15 +++++++++++++ 10 files changed, 49 insertions(+), 56 deletions(-) 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 index abacaa7c6..26e5dddd8 100644 --- 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 @@ -87,14 +87,13 @@ addCartItem( #### Body Parameters -| Parameter | Type | Description | -| --------- | -------- | -------------------------------------------------- | -| cartId | string | Existing cart ID (optional) | -| productId | string | Product ID (required) | -| variantId | string | Variant ID (required by some backends e.g. Medusa) | -| quantity | number | Quantity (required) | -| currency | Currency | Required when creating new cart | -| regionId | string | Required when creating new cart (e.g. Medusa) | +| 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 @@ -254,8 +253,7 @@ The carts model supports: | Field | Type | Description | | ------------- | ------- | -------------------------------- | | id | string | Unique identifier | -| productId | string | Product ID | -| variantId | string | Variant ID (optional) | +| sku | string | Product variant SKU | | quantity | number | Quantity | | price | Price | Unit price | | subtotal | Price | Pre-discount subtotal (optional) | diff --git a/packages/framework/src/modules/carts/carts.model.ts b/packages/framework/src/modules/carts/carts.model.ts index 0f9814243..3e6147418 100644 --- a/packages/framework/src/modules/carts/carts.model.ts +++ b/packages/framework/src/modules/carts/carts.model.ts @@ -30,8 +30,7 @@ export class Promotion { export class CartItem { id!: string; - productId!: string; - variantId?: string; + sku!: string; quantity!: number; price!: Price.Price; subtotal?: Price.Price; diff --git a/packages/framework/src/modules/carts/carts.request.ts b/packages/framework/src/modules/carts/carts.request.ts index 29bb99cdd..df7165c3b 100644 --- a/packages/framework/src/modules/carts/carts.request.ts +++ b/packages/framework/src/modules/carts/carts.request.ts @@ -49,8 +49,7 @@ export class DeleteCartParams { // Cart item operations export class AddCartItemBody { cartId?: string; // Optional - if provided, use existing cart; if not, auto-create/find active cart - productId!: string; - variantId?: string; + sku!: string; quantity!: number; currency?: Price.Currency; // Required if creating new cart regionId?: string; // Required if creating new cart (for Medusa.js) diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts index 391e4ea6f..bcd939473 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts @@ -88,7 +88,7 @@ describe('carts.mapper', () => { expect(result.billingAddress?.streetName).toBe('Billing St'); }); - it('should map line items with product_id, variant_id, quantity, unit_price', () => { + it('should map line items with sku from variant_sku, quantity, unit_price', () => { const cart = minimalCart({ items: [ { @@ -111,8 +111,7 @@ describe('carts.mapper', () => { }); const result = mapCart(cart, defaultCurrency); expect(result.items.data).toHaveLength(1); - expect(result.items.data[0]?.productId).toBe('prod_1'); - expect(result.items.data[0]?.variantId).toBe('var_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); }); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index ad66c2b7b..da08cd2a7 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -51,8 +51,7 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca const mapCartItem = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.Currency): Carts.Model.CartItem => { return { id: item.id, - productId: item.product_id ?? '', - variantId: item.variant_id ?? undefined, + sku: item.variant_sku ?? item.variant_id ?? '', quantity: item.quantity, price: mapPrice(item.unit_price, currency) as Models.Price.Price, subtotal: mapPrice(item.subtotal, currency), diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts index e0beb7da1..606673008 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -150,7 +150,7 @@ describe('CartsService', () => { }); describe('addCartItem', () => { - it('should throw BadRequestException when variantId is missing', () => { + it('should throw BadRequestException when sku is missing', () => { expect(() => service.addCartItem({ quantity: 1 } as Carts.Request.AddCartItemBody, 'Bearer token')).toThrow( BadRequestException, ); @@ -158,10 +158,7 @@ describe('CartsService', () => { it('should throw BadRequestException when cartId absent and currency missing', () => { expect(() => - service.addCartItem( - { variantId: 'var_1', quantity: 1 } as Carts.Request.AddCartItemBody, - 'Bearer token', - ), + service.addCartItem({ sku: 'SKU1', quantity: 1 } as Carts.Request.AddCartItemBody, 'Bearer token'), ).toThrow(BadRequestException); }); @@ -174,7 +171,7 @@ describe('CartsService', () => { const result = await firstValueFrom( service.addCartItem( - { cartId: 'cart_1', variantId: 'var_1', quantity: 2 } as Carts.Request.AddCartItemBody, + { cartId: 'cart_1', sku: 'SKU1', quantity: 2 } as Carts.Request.AddCartItemBody, 'Bearer token', ), ); @@ -182,7 +179,7 @@ describe('CartsService', () => { expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith('cart_1', {}, expect.any(Object)); expect(mockSdk.store.cart.createLineItem).toHaveBeenCalledWith( 'cart_1', - { variant_id: 'var_1', quantity: 2, metadata: undefined }, + { variant_id: 'SKU1', quantity: 2, metadata: undefined }, {}, expect.any(Object), ); @@ -197,7 +194,7 @@ describe('CartsService', () => { const result = await firstValueFrom( service.addCartItem( - { cartId: 'cart_1', variantId: 'var_1', quantity: 1 } as Carts.Request.AddCartItemBody, + { cartId: 'cart_1', sku: 'SKU1', quantity: 1 } as Carts.Request.AddCartItemBody, 'Bearer token', ), ); @@ -212,7 +209,7 @@ describe('CartsService', () => { const result = await firstValueFrom( service.addCartItem( - { variantId: 'var_1', quantity: 2, currency: 'EUR' } as Carts.Request.AddCartItemBody, + { sku: 'SKU1', quantity: 2, currency: 'EUR' } as Carts.Request.AddCartItemBody, undefined, ), ); @@ -233,7 +230,7 @@ describe('CartsService', () => { const result = await firstValueFrom( service.addCartItem( - { variantId: 'var_1', quantity: 1, currency: 'EUR' } as Carts.Request.AddCartItemBody, + { sku: 'SKU1', quantity: 1, currency: 'EUR' } as Carts.Request.AddCartItemBody, 'Bearer token', ), ); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 2e7ccbfc9..999ddbb3c 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -140,32 +140,30 @@ export class CartsService extends Carts.Service { } addCartItem(data: Carts.Request.AddCartItemBody, authorization?: string): Observable { - if (!data.variantId) { - throw new BadRequestException('Variant ID is required for Medusa carts'); + 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; // Store for type narrowing + const cartId = data.cartId; return from( this.sdk.store.cart.retrieve(cartId, {}, this.medusaJsService.getStoreApiHeaders(authorization)), ).pipe( switchMap((response: HttpTypes.StoreCartResponse) => { const cart = mapCart(response.cart, this.defaultCurrency); - // Verify ownership for customer carts if (cart.customerId && authorization && cart.customerId !== customerId) { throw new UnauthorizedException('Unauthorized to modify this cart'); } - // Add item to existing cart return from( this.sdk.store.cart.createLineItem( cartId, { - variant_id: data.variantId, + variant_id: data.sku, quantity: data.quantity, metadata: data.metadata, }, @@ -179,14 +177,13 @@ export class CartsService extends Carts.Service { ); } - // No cartId provided - create a new cart if (!data.currency) { throw new BadRequestException('Currency is required when creating a new cart'); } return this.createCartAndAddItem( data.currency, - data.variantId, + data.sku, data.quantity, data.regionId, data.metadata, @@ -312,7 +309,7 @@ export class CartsService extends Carts.Service { private createCartAndAddItem( currency: string, - variantId: string, + sku: string, quantity: number, regionId?: string, metadata?: Record, @@ -334,7 +331,7 @@ export class CartsService extends Carts.Service { this.sdk.store.cart.createLineItem( createResponse.cart.id, { - variant_id: variantId, + variant_id: sku, quantity, metadata, }, diff --git a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts index 9d1a1c572..ee3b7d11e 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -1,7 +1,7 @@ import { Carts, Models, Products } from '@o2s/framework/modules'; import { getMockProviderById, getPaymentMethodDisplay } from '../payments/mocks/providers.mock'; -import { mapProduct } from '../products/products.mapper'; +import { mapProductBySku } from '../products/products.mapper'; // Read payment method stored in metadata by setPayment const mapPaymentMethodFromMetadata = (metadata: Record): Carts.Model.PaymentMethod | undefined => { @@ -55,8 +55,7 @@ const buildCartItemFromProduct = ( return { id: `ITEM-${itemIndex.toString().padStart(3, '0')}`, - productId: product.id, - variantId: undefined, + sku: product.sku ?? '', quantity, price: { value: price, currency }, subtotal: { value: subtotal, currency }, @@ -235,12 +234,7 @@ export const findActiveCartByCustomerId = (customerId: string | undefined): Cart return cartsStore.find((cart) => cart.customerId === customerId && cart.type === 'ACTIVE'); }; -const matchesProductAndVariant = (item: Carts.Model.CartItem, productId: string, variantId?: string): boolean => { - if (item.productId !== productId) return false; - const itemVariant = item.variantId ?? undefined; - const reqVariant = variantId ?? undefined; - return itemVariant === reqVariant; -}; +const matchesSku = (item: Carts.Model.CartItem, sku: string): boolean => item.sku === sku; export const addCartItem = (cartId: string, data: Carts.Request.AddCartItemBody): Carts.Model.Cart | undefined => { const cartIndex = cartsStore.findIndex((cart) => cart.id === cartId); @@ -248,17 +242,14 @@ export const addCartItem = (cartId: string, data: Carts.Request.AddCartItemBody) let product: Products.Model.Product; try { - product = mapProduct(data.productId); + product = mapProductBySku(data.sku); } catch { - return undefined; // Product not found + return undefined; } const cart = cartsStore[cartIndex]!; - // Find existing item with same product and variant — merge quantity instead of adding duplicate - const existingIndex = cart.items.data.findIndex((item) => - matchesProductAndVariant(item, data.productId, data.variantId), - ); + const existingIndex = cart.items.data.findIndex((item) => matchesSku(item, data.sku)); if (existingIndex !== -1) { const item = cart.items.data[existingIndex]!; @@ -277,7 +268,6 @@ export const addCartItem = (cartId: string, data: Carts.Request.AddCartItemBody) cart.currency, data.metadata || {}, ); - newItem.variantId = data.variantId; cart.items.data.push(newItem); } diff --git a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts index 98bdd0d5d..c940a77fb 100644 --- a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts @@ -511,7 +511,7 @@ export function mapOrderFromCart(cart: Carts.Model.Cart, email?: string): Orders // 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.productId, + productId: item.product.id, quantity: item.quantity, price: item.price, total: item.total, diff --git a/packages/integrations/mocked/src/modules/products/products.mapper.ts b/packages/integrations/mocked/src/modules/products/products.mapper.ts index d07f1bd6a..c6c4e5e96 100644 --- a/packages/integrations/mocked/src/modules/products/products.mapper.ts +++ b/packages/integrations/mocked/src/modules/products/products.mapper.ts @@ -19,6 +19,21 @@ export const mapProduct = (id: string, locale?: string): Products.Model.Product 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; From 986bfb6c54bd9ddc70c7468b9874de3a9a86371c Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 16 Feb 2026 15:22:55 +0100 Subject: [PATCH 11/27] refactor: standardize currency handling and improve field mappings across modules --- .../src/modules/carts/carts.mapper.ts | 49 +++++++++++++------ .../src/modules/carts/carts.service.spec.ts | 25 ++++++++-- .../src/modules/checkout/checkout.mapper.ts | 6 ++- .../src/modules/checkout/checkout.service.ts | 3 +- .../src/modules/customers/customers.mapper.ts | 12 ++--- .../src/modules/orders/orders.mapper.spec.ts | 2 +- .../src/modules/orders/orders.mapper.ts | 43 ++++++++-------- .../src/modules/products/products.mapper.ts | 11 ++--- .../src/modules/products/products.service.ts | 3 ++ .../resources/resources.mapper.spec.ts | 4 +- .../src/modules/resources/resources.mapper.ts | 36 ++++++++------ .../medusajs/src/utils/currency.ts | 11 +++++ .../medusajs/src/utils/metadata.ts | 6 +++ .../integrations/medusajs/src/utils/price.ts | 12 +++++ 14 files changed, 152 insertions(+), 71 deletions(-) create mode 100644 packages/integrations/medusajs/src/utils/currency.ts create mode 100644 packages/integrations/medusajs/src/utils/metadata.ts create mode 100644 packages/integrations/medusajs/src/utils/price.ts diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index da08cd2a7..bf3639eb2 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -2,6 +2,10 @@ import { HttpTypes } from '@medusajs/types'; import { Carts, Models, Orders, Products } from '@o2s/framework/modules'; +import { parseCurrency } from '@/utils/currency'; +import { asRecord } from '@/utils/metadata'; +import { mapPriceRequired } from '@/utils/price'; + export const mapCarts = ( carts: { carts: HttpTypes.StoreCart[]; count?: number }, defaultCurrency: string, @@ -16,7 +20,7 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca if (!cart.currency_code) { throw new Error(`Cart ${cart.id} has no currency code`); } - const currency = cart.currency_code.toUpperCase() as Models.Price.Currency; + const currency = parseCurrency(cart.currency_code); return { id: cart.id, @@ -36,13 +40,13 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca discountTotal: mapPrice(cart.discount_total, currency), taxTotal: mapPrice(cart.tax_total, currency), shippingTotal: mapPrice(cart.shipping_total, currency), - total: mapPrice(cart.total, currency) as Models.Price.Price, + total: mapPriceRequired(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((cart.metadata as Record) ?? {}), + paymentMethod: mapPaymentMethodFromMetadata(asRecord(cart.metadata)), promotions: mapPromotions(cart), - metadata: (cart.metadata as Record) ?? {}, + metadata: asRecord(cart.metadata), notes: undefined, email: cart.email ?? undefined, }; @@ -53,14 +57,14 @@ const mapCartItem = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.C id: item.id, sku: item.variant_sku ?? item.variant_id ?? '', quantity: item.quantity, - price: mapPrice(item.unit_price, currency) as Models.Price.Price, + price: mapPriceRequired(item.unit_price, currency, `Cart item ${item.id} unit_price`), subtotal: mapPrice(item.subtotal, currency), discountTotal: mapPrice(item.discount_total, currency), - total: mapPrice(item.total, currency) as Models.Price.Price, + total: mapPriceRequired(item.total, currency, `Cart item ${item.id} total`), unit: 'PCS', currency, product: mapProduct(item, currency), - metadata: (item.metadata as Record) ?? {}, + metadata: asRecord(item.metadata), }; }; @@ -77,9 +81,9 @@ const mapProduct = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.Cu alt: item.product_title ?? item.title ?? '', } : undefined, - price: mapPrice(item.unit_price, currency) as Models.Price.Price, + price: mapPriceRequired(item.unit_price, currency, `Cart product ${item.product_id} unit_price`), link: '', - type: 'PHYSICAL' as Products.Model.ProductType, + type: 'PHYSICAL', category: '', tags: [], }; @@ -102,15 +106,30 @@ const mapAddress = (address?: HttpTypes.StoreCartAddress | null): Models.Address }; }; +const VALID_PAYMENT_METHOD_TYPES: Carts.Model.PaymentMethodType[] = ['CREDIT_CARD', 'PAYPAL', 'BANK_TRANSFER', 'OTHER']; + const mapPaymentMethodFromMetadata = (metadata: Record): Carts.Model.PaymentMethod | undefined => { - const stored = metadata?.paymentMethod as Record | undefined; - if (!stored || typeof stored !== 'object') return 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; + const typeVal = storedObj.type; + const type: Carts.Model.PaymentMethodType = + typeof typeVal === 'string' && VALID_PAYMENT_METHOD_TYPES.includes(typeVal as Carts.Model.PaymentMethodType) + ? (typeVal as Carts.Model.PaymentMethodType) + : 'OTHER'; return { - id: stored.id as string, - name: stored.name as string, - description: (stored.description as string) ?? undefined, - type: (stored.type as Carts.Model.PaymentMethodType) ?? 'OTHER', + id, + name, + description: typeof description === 'string' ? description : undefined, + type, }; }; diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts index 606673008..1d3f065dc 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -11,6 +11,23 @@ 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, @@ -165,7 +182,7 @@ describe('CartsService', () => { it('should retrieve then createLineItem when cartId provided', async () => { mockSdk.store.cart.retrieve.mockResolvedValue({ cart: minimalCart }); mockSdk.store.cart.createLineItem.mockResolvedValue({ - cart: { ...minimalCart, items: [{ id: 'item_1' }] }, + cart: { ...minimalCart, items: [minimalCartItem] }, }); mockAuthService.getCustomerId.mockReturnValue(undefined); @@ -319,7 +336,7 @@ describe('CartsService', () => { it('should update cart with inline shipping and billing addresses', async () => { const cartWithItems = { ...minimalCart, - items: [{ id: 'item_1', quantity: 1 }], + items: [minimalCartItem], metadata: {}, }; mockSdk.store.cart.retrieve.mockResolvedValue({ cart: cartWithItems }); @@ -375,7 +392,7 @@ describe('CartsService', () => { it('should add shipping method when cart has items', async () => { const cartWithItems = { ...minimalCart, - items: [{ id: 'item_1', quantity: 1 }], + items: [minimalCartItem], }; mockSdk.store.cart.retrieve.mockResolvedValue({ cart: cartWithItems }); mockSdk.store.cart.addShippingMethod.mockResolvedValue({ cart: cartWithItems }); @@ -462,7 +479,7 @@ describe('CartsService', () => { it('should return cart when valid', async () => { const cartWithItems = { ...minimalCart, - items: [{ id: 'item_1', quantity: 1 }], + items: [minimalCartItem], }; mockSdk.store.cart.retrieve.mockResolvedValue({ cart: cartWithItems }); diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts index 23729229e..dcd6095d9 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts @@ -1,7 +1,9 @@ import { HttpTypes } from '@medusajs/types'; import { BadRequestException } from '@nestjs/common'; -import { Carts, Checkout, Models, Orders, Payments } from '@o2s/framework/modules'; +import { Carts, Checkout, Orders, Payments } from '@o2s/framework/modules'; + +import { parseCurrency } from '@/utils/currency'; export function mapCheckoutSummary( cart: Carts.Model.Cart, @@ -96,7 +98,7 @@ function mapShippingOption( if (!currencyCode) { throw new BadRequestException(`Shipping option ${option.id} has no currency information`); } - const currency = currencyCode as Models.Price.Currency; + const currency = parseCurrency(currencyCode); const amountWithoutTax = calculatedPrice?.calculated_amount_without_tax ?? amount; diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts index bb46e2e3d..5f003a800 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts @@ -144,7 +144,8 @@ export class CheckoutService extends Checkout.Service { return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); } - const paymentSessionId = cart.metadata?.paymentSessionId as string | undefined; + const id = cart.metadata?.paymentSessionId; + const paymentSessionId = typeof id === 'string' ? id : undefined; if (paymentSessionId) { return this.paymentsService diff --git a/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts index 1497c3ca5..fb0889986 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.mapper.ts @@ -12,16 +12,16 @@ export function mapCustomerAddress( 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, - lastName: medusaAddress.last_name, + firstName: medusaAddress.first_name ?? undefined, + lastName: medusaAddress.last_name ?? undefined, country: medusaAddress.country_code || '', streetName: medusaAddress.address_1 || '', - streetNumber: medusaAddress.address_2, + streetNumber: medusaAddress.address_2 ?? undefined, city: medusaAddress.city || '', postalCode: medusaAddress.postal_code || '', - region: medusaAddress.province, - phone: medusaAddress.phone, - } as Models.Address.Address, + region: medusaAddress.province ?? undefined, + phone: medusaAddress.phone ?? undefined, + }, createdAt: medusaAddress.created_at || new Date().toISOString(), updatedAt: medusaAddress.updated_at || new Date().toISOString(), }; 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 24c2d222c..c94f8adba 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.mapper.spec.ts @@ -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 46b99e2d5..8a7634685 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts @@ -3,6 +3,9 @@ import { NotFoundException } from '@nestjs/common'; import { Models, Orders, Products } from '@o2s/framework/modules'; +import { parseCurrency } from '@/utils/currency'; +import { mapPriceRequired } from '@/utils/price'; + export const mapOrders = (orders: HttpTypes.StoreOrderListResponse, defaultCurrency: string): Orders.Model.Orders => { return { data: orders.orders.map((order) => mapOrder(order, defaultCurrency)), @@ -11,49 +14,49 @@ export const mapOrders = (orders: HttpTypes.StoreOrderListResponse, defaultCurre }; 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: mapPriceRequired(order.total, currency, `Order ${order.id} total`), + subtotal: mapPrice(order.subtotal, currency), + shippingTotal: mapPrice(order.shipping_total, currency), + discountTotal: mapPrice(order.discount_total, currency), + tax: mapPrice(order.tax_total, currency), + 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.StoreOrderLineItem, 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, + price: mapPriceRequired(item.unit_price, currency, `Order item ${item.id} unit_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, + currency, + product: mapProduct(item.unit_price, currency, item), }; }; const mapProduct = ( unitPrice: number, - currency: string, + currency: Models.Price.Currency, item?: HttpTypes.StoreOrderLineItem, ): Products.Model.Product => { if (!item) throw new NotFoundException('Product not found'); @@ -70,9 +73,9 @@ const mapProduct = ( alt: item.product_title || item.title, } : undefined, - price: mapPrice(unitPrice, currency) as Models.Price.Price, + price: mapPriceRequired(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: [], }; @@ -97,7 +100,7 @@ const mapAddress = (address?: HttpTypes.StoreOrderAddress | null): Models.Addres const mapShippingMethod = ( method: HttpTypes.StoreOrderShippingMethod, - currency: string, + currency: Models.Price.Currency, ): Orders.Model.ShippingMethod => { return { id: method.id, @@ -108,11 +111,11 @@ const mapShippingMethod = ( }; }; -const mapPrice = (value: number, currency: string): Models.Price.Price | undefined => { +const mapPrice = (value: number, currency: Models.Price.Currency): Models.Price.Price | undefined => { if (typeof value === 'undefined') return undefined; return { value, - currency: currency as Models.Price.Currency, + currency, }; }; diff --git a/packages/integrations/medusajs/src/modules/products/products.mapper.ts b/packages/integrations/medusajs/src/modules/products/products.mapper.ts index 179201a46..a0e23bfbc 100644 --- a/packages/integrations/medusajs/src/modules/products/products.mapper.ts +++ b/packages/integrations/medusajs/src/modules/products/products.mapper.ts @@ -1,11 +1,12 @@ import { HttpTypes } from '@medusajs/types'; import { NotFoundException } from '@nestjs/common'; -import { Models, Products } from '@o2s/framework/modules'; +import { Products } from '@o2s/framework/modules'; import { CompatibleServicesResponse, FeaturedServicesResponse } from '../resources/response.types'; import { RelatedProductsResponse } from './response.types'; +import { parseCurrency } from '@/utils/currency'; // Fields to extract as detailed specs from variant const VARIANT_SPEC_FIELDS = [ @@ -165,7 +166,7 @@ export const mapProduct = (productVariant: AnyProductVariant, defaultCurrency: s })), price: { value: price.amount, - currency: price.currencyCode as Models.Price.Currency, + currency: parseCurrency(price.currencyCode), }, link: `/products/${product?.id || ''}`, type: mapProductType(product?.type || undefined), @@ -217,7 +218,7 @@ export const mapProducts = ( })), price: { value: price.amount, - currency: price.currencyCode as Models.Price.Currency, + currency: parseCurrency(price.currencyCode), }, link: `/products/${product.id}`, type: mapProductType(product?.type || undefined), @@ -256,9 +257,7 @@ export const mapRelatedProducts = (data: RelatedProductsResponse, defaultCurrenc })), price: { value: price?.amount || 0, - currency: - (price?.currency_code?.toUpperCase() as Models.Price.Currency) || - (defaultCurrency as Models.Price.Currency), + currency: parseCurrency(price?.currency_code ?? defaultCurrency), }, link: `/products/${product?.id || ''}`, type: mapProductType(product?.type || undefined), diff --git a/packages/integrations/medusajs/src/modules/products/products.service.ts b/packages/integrations/medusajs/src/modules/products/products.service.ts index 83e58095a..a15d9631c 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.ts @@ -81,6 +81,9 @@ export class ProductsService extends Products.Service { if (!product?.variants?.length) { throw new NotFoundException(`No variants found for product ${params.id}`); } + if (!Array.isArray(product.variants)) { + throw new NotFoundException(`Product ${params.id} has invalid variants`); + } // Find the requested variant, or use the first one const variants = product.variants as HttpTypes.StoreProductVariant[]; 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 d9b7bdec4..ba1b354fe 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts @@ -3,6 +3,7 @@ import { AddressDTO } from '@medusajs/types'; import { Models, Products, Resources } from '@o2s/framework/modules'; import { Asset, AssetsResponse, ServiceInstance, ServiceInstancesResponse } from './response.types'; +import { parseCurrency } from '@/utils/currency'; export const mapAsset = (asset: Asset, product: Products.Model.Product): Resources.Model.Asset => { return { @@ -67,13 +68,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: @@ -104,15 +105,22 @@ const mapAddress = (address: AddressDTOWithNames): Models.Address.Address | unde }; }; -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; +const VALID_CONTRACT_STATUSES: Resources.Model.ContractStatus[] = ['ACTIVE', 'EXPIRED', 'INACTIVE']; + +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/utils/currency.ts b/packages/integrations/medusajs/src/utils/currency.ts new file mode 100644 index 000000000..19200988f --- /dev/null +++ b/packages/integrations/medusajs/src/utils/currency.ts @@ -0,0 +1,11 @@ +import { Models } from '@o2s/framework/modules'; + +const VALID_CURRENCIES: Models.Price.Currency[] = ['USD', 'EUR', 'GBP', 'PLN']; + +export function parseCurrency(code: string | undefined | null): Models.Price.Currency { + const normalized = (code ?? '').toUpperCase(); + if (VALID_CURRENCIES.includes(normalized as Models.Price.Currency)) { + return normalized as Models.Price.Currency; + } + throw new Error(`Invalid or unsupported currency: ${code}`); +} 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..c15621486 --- /dev/null +++ b/packages/integrations/medusajs/src/utils/price.ts @@ -0,0 +1,12 @@ +import { Models } from '@o2s/framework/modules'; + +export function mapPriceRequired( + value: number | undefined | null, + currency: Models.Price.Currency, + context: string, +): Models.Price.Price { + if (value === undefined || value === null || typeof value !== 'number') { + throw new Error(`${context}: price value is missing or invalid`); + } + return { value, currency }; +} From 871587ab392631e78d8b9162db8140bff80e5898 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 16 Feb 2026 15:43:45 +0100 Subject: [PATCH 12/27] refactor: add `locale` support to cart models, mappers, and services - Updated request parameters across cart-related models to include `locale` field. - Fixed incorrect HTTP method in promotion removal logic. - Updated checkout service to accept dynamic `returnUrl` and `cancelUrl` values. --- .../src/modules/carts/carts.request.ts | 7 ++++++ .../src/modules/carts/carts.mapper.ts | 1 - .../src/modules/carts/carts.service.spec.ts | 15 +++++------ .../src/modules/carts/carts.service.ts | 10 +++----- .../mocked/src/modules/carts/carts.mapper.ts | 25 +++++++++++-------- .../mocked/src/modules/carts/carts.service.ts | 2 +- .../src/modules/checkout/checkout.service.ts | 4 +-- .../src/modules/orders/orders.mapper.ts | 2 +- 8 files changed, 37 insertions(+), 29 deletions(-) diff --git a/packages/framework/src/modules/carts/carts.request.ts b/packages/framework/src/modules/carts/carts.request.ts index df7165c3b..6da1f36a5 100644 --- a/packages/framework/src/modules/carts/carts.request.ts +++ b/packages/framework/src/modules/carts/carts.request.ts @@ -22,6 +22,7 @@ export class CreateCartBody { regionId?: string; currency!: Price.Currency; metadata?: Record; + locale?: string; } export class UpdateCartParams { @@ -40,6 +41,7 @@ export class UpdateCartBody { paymentMethodType?: PaymentMethodType; notes?: string; metadata?: Record; + locale?: string; } export class DeleteCartParams { @@ -54,6 +56,7 @@ export class AddCartItemBody { 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 { @@ -64,6 +67,7 @@ export class UpdateCartItemParams { export class UpdateCartItemBody { quantity?: number; metadata?: Record; + locale?: string; } export class RemoveCartItemParams { @@ -78,6 +82,7 @@ export class ApplyPromotionParams { export class ApplyPromotionBody { code!: string; + locale?: string; } export class RemovePromotionParams { @@ -102,6 +107,7 @@ export class UpdateCartAddressesBody { billingAddress?: Address.Address; // Or provide new address notes?: string; email?: string; // For guest checkout + locale?: string; } // Shipping method operations @@ -111,4 +117,5 @@ export class AddShippingMethodParams { export class AddShippingMethodBody { shippingOptionId!: string; // Shipping option ID from getShippingOptions() + locale?: string; } diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index bf3639eb2..f3d6c7573 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -98,7 +98,6 @@ const mapAddress = (address?: HttpTypes.StoreCartAddress | null): Models.Address 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 ?? '', diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts index 1d3f065dc..05149eab8 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -1,6 +1,5 @@ import { HttpTypes } from '@medusajs/types'; -import { BadRequestException } from '@nestjs/common'; -import { NotFoundException, NotImplementedException, UnauthorizedException } from '@nestjs/common'; +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'; @@ -173,10 +172,12 @@ describe('CartsService', () => { ); }); - it('should throw BadRequestException when cartId absent and currency missing', () => { - expect(() => - service.addCartItem({ sku: 'SKU1', 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 () => { @@ -466,7 +467,7 @@ describe('CartsService', () => { expect(mockSdk.client.fetch).toHaveBeenCalledWith( '/store/carts/cart_1/promotions', expect.objectContaining({ - method: 'POST', + method: 'DELETE', body: expect.objectContaining({ promo_codes: ['SAVE10'] }), }), ); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 999ddbb3c..3929c7021 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -156,7 +156,7 @@ export class CartsService extends Carts.Service { const cart = mapCart(response.cart, this.defaultCurrency); if (cart.customerId && authorization && cart.customerId !== customerId) { - throw new UnauthorizedException('Unauthorized to modify this cart'); + return throwError(() => new BadRequestException('Variant ID is required for Medusa carts')); } return from( @@ -178,7 +178,7 @@ export class CartsService extends Carts.Service { } if (!data.currency) { - throw new BadRequestException('Currency is required when creating a new cart'); + return throwError(() => new BadRequestException('Currency is required when creating a new cart')); } return this.createCartAndAddItem( @@ -256,7 +256,7 @@ export class CartsService extends Carts.Service { removePromotion(params: Carts.Request.RemovePromotionParams, authorization?: string): Observable { return from( this.sdk.client.fetch(`/store/carts/${params.cartId}/promotions`, { - method: 'POST', + method: 'DELETE', headers: { 'Content-Type': 'application/json', ...this.medusaJsService.getStoreApiHeaders(authorization), @@ -350,9 +350,7 @@ export class CartsService extends Carts.Service { data: Carts.Request.UpdateCartAddressesBody, authorization?: string, ): Observable { - const headers = authorization - ? this.medusaJsService.getStoreApiHeaders(authorization) - : this.medusaJsService.getStoreApiHeaders(authorization); + const headers = this.medusaJsService.getStoreApiHeaders(authorization); // Resolve shipping address const shippingAddress$ = diff --git a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts index ee3b7d11e..88cb83efd 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -46,7 +46,6 @@ const formatDate = (date: Date): string => { const buildCartItemFromProduct = ( product: Products.Model.Product, quantity: number, - itemIndex: number, currency: Models.Price.Currency, metadata: Record = {}, ): Carts.Model.CartItem => { @@ -54,7 +53,7 @@ const buildCartItemFromProduct = ( const subtotal = price * quantity; return { - id: `ITEM-${itemIndex.toString().padStart(3, '0')}`, + id: `ITEM-${crypto.randomUUID()}`, sku: product.sku ?? '', quantity, price: { value: price, currency }, @@ -236,13 +235,17 @@ export const findActiveCartByCustomerId = (customerId: string | undefined): Cart const matchesSku = (item: Carts.Model.CartItem, sku: string): boolean => item.sku === sku; -export const addCartItem = (cartId: string, data: Carts.Request.AddCartItemBody): Carts.Model.Cart | undefined => { +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); + product = mapProductBySku(data.sku, locale); } catch { return undefined; } @@ -261,13 +264,7 @@ export const addCartItem = (cartId: string, data: Carts.Request.AddCartItemBody) item.metadata = { ...(item.metadata || {}), ...data.metadata }; } } else { - const newItem = buildCartItemFromProduct( - product, - data.quantity, - cart.items.data.length, - cart.currency, - data.metadata || {}, - ); + const newItem = buildCartItemFromProduct(product, data.quantity, cart.currency, data.metadata || {}); cart.items.data.push(newItem); } @@ -295,6 +292,10 @@ export const updateCartItem = ( 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 }; @@ -392,6 +393,8 @@ const recalculateCartTotals = (cart: Carts.Model.Cart): void => { } } + 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; diff --git a/packages/integrations/mocked/src/modules/carts/carts.service.ts b/packages/integrations/mocked/src/modules/carts/carts.service.ts index 13a976729..35c107509 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.service.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.service.ts @@ -193,7 +193,7 @@ export class CartsService implements Carts.Service { } // Add item to cart - const updatedCart = addCartItem(cartId, data); + const updatedCart = addCartItem(cartId, data, data.locale); if (!updatedCart) { throw new NotFoundException('Cart or product not found'); } diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts index 941a9bdd5..8382f843a 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -83,8 +83,8 @@ export class CheckoutService implements Checkout.Service { { cartId: params.cartId, providerId: data.providerId, - returnUrl: 'https://example.com/checkout/return', - cancelUrl: 'https://example.com/checkout/cancel', + returnUrl: data.returnUrl, + cancelUrl: data.cancelUrl, metadata: data.metadata, }, authorization, diff --git a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts index c940a77fb..221cbef8f 100644 --- a/packages/integrations/mocked/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/mocked/src/modules/orders/orders.mapper.ts @@ -553,6 +553,6 @@ export function mapOrderFromCart(cart: Carts.Model.Cart, email?: string): Orders billingAddress: cart.billingAddress, shippingMethods: [cart.shippingMethod], customerComment: cart.notes, - email, + email: email ?? cart.email, }; } From c9002b7830ab3fd16e7bc9930c81815d854fe066 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 16 Feb 2026 15:47:11 +0100 Subject: [PATCH 13/27] refactor: simplify cart item validation in checkout service --- .../src/modules/checkout/checkout.service.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts index 8382f843a..3299c19e1 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -2,13 +2,34 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm 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( @@ -27,7 +48,7 @@ export class CheckoutService implements Checkout.Service { return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); } - if (!cart.items || cart.items.data.length === 0) { + if (!cart.items?.data?.length) { return throwError(() => new BadRequestException('Cart must have items before checkout')); } From f5c7da241c4cc7079f2e95bd1d9f0b0e232d7768 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Mon, 16 Feb 2026 16:11:50 +0100 Subject: [PATCH 14/27] refactor: improve error handling and standardize imports across modules - Added stricter validation for payment and shipping methods in the checkout service. - Refactored currency handling to use default currency when missing. - Standardized import paths for `handleHttpError` utility across modules. --- .../src/modules/carts/carts.service.ts | 4 +-- .../modules/checkout/checkout.mapper.spec.ts | 11 ++++--- .../src/modules/checkout/checkout.mapper.ts | 8 ++--- .../src/modules/checkout/checkout.service.ts | 30 +++++++++++-------- .../modules/customers/customers.service.ts | 2 +- .../src/modules/orders/orders.service.ts | 2 +- .../src/modules/payments/payments.service.ts | 2 +- .../src/modules/products/products.service.ts | 2 +- .../src/modules/resources/resources.mapper.ts | 2 +- .../modules/resources/resources.service.ts | 2 +- .../utils/handle-http-error.spec.ts | 0 .../{modules => }/utils/handle-http-error.ts | 0 .../integrations/medusajs/src/utils/price.ts | 2 +- .../mocked/src/modules/carts/carts.service.ts | 4 +-- .../src/modules/checkout/checkout.service.ts | 21 ------------- 15 files changed, 39 insertions(+), 53 deletions(-) rename packages/integrations/medusajs/src/{modules => }/utils/handle-http-error.spec.ts (100%) rename packages/integrations/medusajs/src/{modules => }/utils/handle-http-error.ts (100%) diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 3929c7021..07d96779c 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -17,8 +17,8 @@ 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 { handleHttpError } from '../utils/handle-http-error'; import { mapCart } from './carts.mapper'; @@ -156,7 +156,7 @@ export class CartsService extends Carts.Service { const cart = mapCart(response.cart, this.defaultCurrency); if (cart.customerId && authorization && cart.customerId !== customerId) { - return throwError(() => new BadRequestException('Variant ID is required for Medusa carts')); + return throwError(() => new UnauthorizedException('Unauthorized to access this cart')); } return from( diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts index 00eacb875..9bb1911bc 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts @@ -187,17 +187,20 @@ describe('checkout.mapper', () => { expect(() => mapShippingOptions(options, 'EUR')).toThrow('no price information'); }); - it('should throw when option has no currency', () => { + it('should use default currency when option has no currency', () => { const options = [ { id: 'opt_1', - name: 'Broken', + name: 'Standard', amount: 500, calculated_price: { calculated_amount: 500, currency_code: undefined }, }, ] as unknown as Parameters[0]; - expect(() => mapShippingOptions(options, 'EUR')).toThrow(BadRequestException); - expect(() => mapShippingOptions(options, 'EUR')).toThrow('no currency information'); + 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 index dcd6095d9..1c5d1d0b1 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.ts @@ -85,7 +85,7 @@ export function mapShippingOptions( function mapShippingOption( option: HttpTypes.StoreCartShippingOption, - _defaultCurrency: string, + defaultCurrency: string, ): Orders.Model.ShippingMethod { const calculatedPrice = option.calculated_price; @@ -94,10 +94,8 @@ function mapShippingOption( throw new BadRequestException(`Shipping option ${option.id} has no price information`); } - const currencyCode = calculatedPrice?.currency_code?.toUpperCase(); - if (!currencyCode) { - throw new BadRequestException(`Shipping option ${option.id} has no currency information`); - } + const currencyCode = (calculatedPrice?.currency_code || defaultCurrency)?.toUpperCase(); + const currency = parseCurrency(currencyCode); const amountWithoutTax = calculatedPrice?.calculated_amount_without_tax ?? amount; diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts index 5f003a800..0bd22ab5c 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts @@ -10,8 +10,8 @@ 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 { handleHttpError } from '../utils/handle-http-error'; import { mapCheckoutSummary, mapPlaceOrderResponse, mapShippingOptions } from './checkout.mapper'; @@ -178,6 +178,10 @@ export class CheckoutService extends Checkout.Service { 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) { @@ -318,20 +322,22 @@ export class CheckoutService extends Checkout.Service { { shippingOptionId: data.shippingMethodId }, authorization, ) - : of(null), + : throwError(() => new BadRequestException('Shipping method is required for checkout')), ), switchMap(() => // Setup payment - this.setPayment( - { cartId: params.cartId }, - { - providerId: data.paymentProviderId, - returnUrl: data.returnUrl, - cancelUrl: data.cancelUrl, - metadata: data.metadata, - }, - authorization, - ), + 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 diff --git a/packages/integrations/medusajs/src/modules/customers/customers.service.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.ts index b5572c7c4..3fe6d860c 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.service.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.ts @@ -9,7 +9,7 @@ import { Auth, Customers } 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 { mapAddressToMedusa, mapCustomerAddress, mapCustomerAddresses } from './customers.mapper'; diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.ts index 4e969fc6f..79569049f 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.ts @@ -10,7 +10,7 @@ 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'; diff --git a/packages/integrations/medusajs/src/modules/payments/payments.service.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.ts index 4f3631f16..b164c0c57 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.service.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.ts @@ -10,7 +10,7 @@ import { Payments } 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 { mapPaymentProviders, mapPaymentSession } from './payments.mapper'; diff --git a/packages/integrations/medusajs/src/modules/products/products.service.ts b/packages/integrations/medusajs/src/modules/products/products.service.ts index a15d9631c..fef78b0dc 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.ts @@ -11,7 +11,7 @@ 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'; diff --git a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts index ba1b354fe..bb30223ea 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts @@ -89,7 +89,7 @@ export const mapService = ( /** AddressDTO with first_name/last_name as returned by the custom resources API */ type AddressDTOWithNames = AddressDTO & { first_name?: string; last_name?: string }; -const mapAddress = (address: AddressDTOWithNames): Models.Address.Address | undefined => { +const mapAddress = (address?: AddressDTOWithNames): Models.Address.Address | undefined => { if (!address) return undefined; return { firstName: address.first_name, diff --git a/packages/integrations/medusajs/src/modules/resources/resources.service.ts b/packages/integrations/medusajs/src/modules/resources/resources.service.ts index 42e1041f1..1902c2be1 100644 --- a/packages/integrations/medusajs/src/modules/resources/resources.service.ts +++ b/packages/integrations/medusajs/src/modules/resources/resources.service.ts @@ -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 { 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 100% 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 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 100% rename from packages/integrations/medusajs/src/modules/utils/handle-http-error.ts rename to packages/integrations/medusajs/src/utils/handle-http-error.ts diff --git a/packages/integrations/medusajs/src/utils/price.ts b/packages/integrations/medusajs/src/utils/price.ts index c15621486..71a45a67f 100644 --- a/packages/integrations/medusajs/src/utils/price.ts +++ b/packages/integrations/medusajs/src/utils/price.ts @@ -5,7 +5,7 @@ export function mapPriceRequired( currency: Models.Price.Currency, context: string, ): Models.Price.Price { - if (value === undefined || value === null || typeof value !== 'number') { + if (!value) { throw new Error(`${context}: price value is missing or invalid`); } return { value, currency }; diff --git a/packages/integrations/mocked/src/modules/carts/carts.service.ts b/packages/integrations/mocked/src/modules/carts/carts.service.ts index 35c107509..8f1f28a49 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.service.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.service.ts @@ -168,7 +168,7 @@ export class CartsService implements Carts.Service { if (!cart) { // Create new active cart if (!data.currency) { - throw new NotFoundException('Currency is required when creating a new cart'); + throw new BadRequestException('Currency is required when creating a new cart'); } cart = createCart({ customerId, @@ -180,7 +180,7 @@ export class CartsService implements Carts.Service { } else { // For guests: create new cart if (!data.currency) { - throw new NotFoundException('Currency is required when creating a new cart'); + throw new BadRequestException('Currency is required when creating a new cart'); } cart = createCart({ currency: data.currency, diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts index 3299c19e1..efd610ebf 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -2,34 +2,13 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm 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( From 1bbc3b36b51b2758a3b3e0c2f7b8862a4b2c5c67 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Tue, 17 Feb 2026 08:49:29 +0100 Subject: [PATCH 15/27] refactor(integrations.medusajs): standardize exception types and improve error handling across modules --- .../src/modules/carts/carts.mapper.ts | 3 +- .../src/modules/carts/carts.service.ts | 3 +- .../customers/customers.service.spec.ts | 44 ++++++- .../modules/customers/customers.service.ts | 42 +++++-- .../src/modules/medusajs/medusajs.service.ts | 8 +- .../src/modules/orders/orders.service.ts | 4 +- .../modules/payments/payments.mapper.spec.ts | 11 +- .../src/modules/payments/payments.mapper.ts | 4 +- .../src/modules/payments/payments.service.ts | 118 +++++------------- .../src/modules/products/products.service.ts | 4 +- .../modules/resources/resources.service.ts | 14 +-- .../medusajs/src/utils/currency.ts | 12 +- .../integrations/medusajs/src/utils/price.ts | 4 +- .../src/modules/checkout/checkout.service.ts | 6 +- 14 files changed, 147 insertions(+), 130 deletions(-) diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index f3d6c7573..fbbfc9ee0 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -1,4 +1,5 @@ import { HttpTypes } from '@medusajs/types'; +import { BadRequestException } from '@nestjs/common'; import { Carts, Models, Orders, Products } from '@o2s/framework/modules'; @@ -18,7 +19,7 @@ export const mapCarts = ( export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Carts.Model.Cart => { if (!cart.currency_code) { - throw new Error(`Cart ${cart.id} has no currency code`); + throw new BadRequestException(`Cart ${cart.id} has no currency code`); } const currency = parseCurrency(cart.currency_code); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 07d96779c..3b1a5781d 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -4,6 +4,7 @@ import { BadRequestException, Inject, Injectable, + InternalServerErrorException, NotFoundException, NotImplementedException, UnauthorizedException, @@ -39,7 +40,7 @@ export class CartsService extends Carts.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'); } } diff --git a/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts index e9bd4b7e0..cc29fc2f3 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts @@ -155,8 +155,24 @@ describe('CustomersService', () => { 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: [minimalAddress] }, + customer: { addresses: [createdAddress] }, } as unknown as HttpTypes.StoreCustomerResponse); const result = await firstValueFrom( @@ -179,11 +195,29 @@ describe('CustomersService', () => { 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: [{ ...minimalAddress, id: 'addr_new' }] }, + customer: { addresses: [createdAddress] }, } as unknown as HttpTypes.StoreCustomerResponse); mockSdk.store.customer.updateAddress.mockResolvedValue({ - customer: { addresses: [{ ...minimalAddress, id: 'addr_new', is_default_shipping: true }] }, + customer: { + addresses: [{ ...createdAddress, is_default_shipping: true, is_default_billing: true }], + }, } as unknown as HttpTypes.StoreCustomerResponse); const result = await firstValueFrom( @@ -198,7 +232,7 @@ describe('CustomersService', () => { expect(mockSdk.store.customer.updateAddress).toHaveBeenCalledWith( 'addr_new', - expect.objectContaining({ is_default_shipping: true }), + expect.objectContaining({ is_default_shipping: true, is_default_billing: true }), {}, expect.any(Object), ); @@ -272,7 +306,7 @@ describe('CustomersService', () => { expect(mockSdk.store.customer.updateAddress).toHaveBeenCalledWith( 'addr_1', - { is_default_shipping: true }, + { is_default_shipping: true, is_default_billing: true }, {}, expect.any(Object), ); diff --git a/packages/integrations/medusajs/src/modules/customers/customers.service.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.ts index 3fe6d860c..629216de3 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.service.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.ts @@ -1,6 +1,12 @@ import Medusa from '@medusajs/js-sdk'; import { HttpTypes } from '@medusajs/types'; -import { Inject, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; +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'; @@ -34,12 +40,12 @@ export class CustomersService extends Customers.Service { getAddresses(authorization: string | undefined): Observable { if (!authorization) { - throw new UnauthorizedException('Authentication required'); + return throwError(() => new UnauthorizedException('Authentication required')); } const customerId = this.authService.getCustomerId(authorization); if (!customerId) { - throw new UnauthorizedException('Invalid authentication'); + return throwError(() => new UnauthorizedException('Invalid authentication')); } return from( @@ -118,10 +124,29 @@ export class CustomersService extends Customers.Service { switchMap((response: HttpTypes.StoreCustomerResponse) => { const customer = response.customer; const addresses = customer.addresses || []; - // The newly created address is typically the last one in the list - const createdAddress = addresses[addresses.length - 1]; + + // 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 Error('Failed to create address')); + return throwError( + () => + new InternalServerErrorException( + 'Failed to create address or find created address in response', + ), + ); } const address = mapCustomerAddress(createdAddress, customerId); @@ -237,7 +262,10 @@ export class CustomersService extends Customers.Service { return from( this.sdk.store.customer.updateAddress( params.id, - { is_default_shipping: true } as Partial, + { + is_default_shipping: true, + is_default_billing: true, + } as Partial, {}, this.medusaJsService.getStoreApiHeaders(authorization), ), diff --git a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts index 334249e68..329112199 100644 --- a/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts +++ b/packages/integrations/medusajs/src/modules/medusajs/medusajs.service.ts @@ -1,5 +1,5 @@ import Medusa from '@medusajs/js-sdk'; -import { Global, Injectable } from '@nestjs/common'; +import { Global, Injectable, InternalServerErrorException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; /** @@ -56,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({ diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.ts index 79569049f..fa96f83d3 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.ts @@ -1,6 +1,6 @@ import Medusa from '@medusajs/js-sdk'; import { HttpTypes, OrderStatus } from '@medusajs/types'; -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Inject, Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Observable, catchError, from, map } from 'rxjs'; @@ -41,7 +41,7 @@ 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'); } } diff --git a/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts index 1c12edd04..b6da76826 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts @@ -54,17 +54,18 @@ describe('payments.mapper', () => { }); describe('mapPaymentProviders', () => { - it('should paginate with offset and limit', () => { + 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, 2, 1); - expect(result.data).toHaveLength(2); + const result = mapPaymentProviders(providers); + expect(result.data).toHaveLength(3); expect(result.total).toBe(3); - expect(result.data[0]?.id).toBe('pp_2'); - expect(result.data[1]?.id).toBe('pp_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'); }); }); diff --git a/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts index 93099d874..85878cef4 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts @@ -15,13 +15,11 @@ export function mapPaymentProvider(medusaProvider: HttpTypes.StorePaymentProvide export function mapPaymentProviders( medusaProviders: HttpTypes.StorePaymentProvider[], - limit = 10, - offset = 0, ): Payments.Model.PaymentProviders { const providers = medusaProviders.map(mapPaymentProvider); return { - data: providers.slice(offset, offset + limit), + data: providers, total: providers.length, }; } diff --git a/packages/integrations/medusajs/src/modules/payments/payments.service.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.ts index b164c0c57..2cb1e0952 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.service.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.ts @@ -1,7 +1,14 @@ import Medusa from '@medusajs/js-sdk'; import { HttpTypes } from '@medusajs/types'; import { HttpService } from '@nestjs/axios'; -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +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'; @@ -24,11 +31,12 @@ import { mapPaymentProviders, mapPaymentSession } from './payments.mapper'; * * - ✅ `getProviders` - Uses SDK: `sdk.store.payment.listPaymentProviders()` * - ✅ `createSession` - Uses SDK: `sdk.store.payment.initiatePaymentSession()` - * - ⚠️ `updateSession` - Uses raw HTTP (SDK method not available) - * - ⚠️ `cancelSession` - Uses raw HTTP (SDK method not available) + * - ❌ `getSession` - Not implemented (SDK method not available) + * - ❌ `updateSession` - Not implemented (SDK method not available) + * - ❌ `cancelSession` - Not implemented (SDK method not available) * - * The `updateSession` and `cancelSession` methods use direct HTTP requests because - * the Medusa.js SDK does not currently expose methods for these operations. + * 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 { @@ -48,7 +56,7 @@ export class PaymentsService extends Payments.Service { authorization: string | undefined, ): Observable { if (!params.regionId) { - throw new BadRequestException('regionId is required to list payment providers'); + return throwError(() => new BadRequestException('regionId is required to list payment providers')); } return from( this.sdk.store.payment.listPaymentProviders( @@ -58,12 +66,12 @@ export class PaymentsService extends Payments.Service { ).pipe( map((response: HttpTypes.StorePaymentProviderListResponse) => { const providers = response.payment_providers || []; - return mapPaymentProviders(providers, 10, 0); + return mapPaymentProviders(providers); }), catchError((error) => { // If endpoint doesn't exist or fails, return empty list this.logger.warn('Failed to fetch payment providers from Medusa', error); - return of(mapPaymentProviders([], 10, 0)); + return of(mapPaymentProviders([])); }), ); } @@ -98,7 +106,7 @@ export class PaymentsService extends Payments.Service { const session = sessions.find((s) => s.provider_id === data.providerId) || sessions[sessions.length - 1]; if (!session) { - throw new Error('Failed to create payment session'); + throw new InternalServerErrorException('Failed to create payment session'); } return mapPaymentSession(session, data.cartId); }), @@ -113,97 +121,37 @@ export class PaymentsService extends Payments.Service { ); } + /** + * 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 { - // Medusa Store API doesn't have a direct endpoint to get payment session by ID - // Payment sessions are accessed through payment collections - // Since we don't have the collection ID or cart ID, we return undefined - // In practice, payment sessions should be retrieved via the cart's payment collection - // This method is primarily used when we have the session ID from cart metadata - // For a complete implementation, we would need to: - // 1. Store payment_collection_id in cart metadata when creating session - // 2. Retrieve collection by ID and find session within it - // For now, return undefined as Medusa doesn't provide a direct lookup - return of(undefined); + throw new NotImplementedException(); } /** - * Updates a payment session using raw HTTP request. + * Updates a payment session. * - * @note The Medusa.js SDK does not provide methods for updating payment sessions. - * This method uses a direct HTTP POST request to `/store/payment-sessions/{id}`. - * Once the SDK adds support for this operation, this should be migrated to use the SDK method. - * - * @see {@link https://docs.medusajs.com/api/store#payment-sessions_postpayment-sessionsid Medusa Store API - Update 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, + _params: Payments.Request.UpdateSessionParams, + _data: Payments.Request.UpdateSessionBody, + _authorization: string | undefined, ): Observable { - const updatePayload: Record = {}; - if (data.returnUrl) { - updatePayload['return_url'] = data.returnUrl; - } - if (data.metadata) { - updatePayload['metadata'] = data.metadata; - } - - return this.httpClient - .post<{ payment_session: HttpTypes.StorePaymentSession }>( - `${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, - updatePayload, - { - headers: this.medusaJsService.getStoreApiHeaders(authorization), - }, - ) - .pipe( - map((response) => { - if (!response?.data) { - throw new Error('Failed to update payment session'); - } - // We don't have cart ID here, so we'll use empty string - // In practice, this should be retrieved from the session or stored context - const cartId = ''; - return mapPaymentSession(response.data.payment_session, cartId); - }), - catchError((error) => { - if (error.response?.status === 404) { - return throwError( - () => new NotFoundException(`Payment session with ID ${params.id} not found`), - ); - } - return handleHttpError(error); - }), - ); + throw new NotImplementedException(); } /** - * Cancels (deletes) a payment session using raw HTTP request. - * - * @note The Medusa.js SDK does not provide methods for deleting payment sessions. - * This method uses a direct HTTP DELETE request to `/store/payment-sessions/{id}`. - * Once the SDK adds support for this operation, this should be migrated to use the SDK method. + * Cancels (deletes) a payment session. * - * @see {@link https://docs.medusajs.com/api/store#payment-sessions_deletepayment-sessionsid Medusa Store API - Delete 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 { - return this.httpClient - .delete(`${this.medusaJsService.getBaseUrl()}/store/payment-sessions/${params.id}`, { - headers: this.medusaJsService.getStoreApiHeaders(authorization), - }) - .pipe( - map(() => undefined), - catchError((error) => { - if (error.response?.status === 404) { - return throwError( - () => new NotFoundException(`Payment session with ID ${params.id} not found`), - ); - } - return handleHttpError(error); - }), - ); + cancelSession(_params: Payments.Request.CancelSessionParams, _authorization: string | undefined): Observable { + throw new NotImplementedException(); } } diff --git a/packages/integrations/medusajs/src/modules/products/products.service.ts b/packages/integrations/medusajs/src/modules/products/products.service.ts index fef78b0dc..1d5288a8f 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.ts @@ -1,7 +1,7 @@ import Medusa from '@medusajs/js-sdk'; import { HttpTypes } from '@medusajs/types'; import { HttpService } from '@nestjs/axios'; -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Observable, catchError, from, map } from 'rxjs'; @@ -45,7 +45,7 @@ export class ProductsService extends Products.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'); } } diff --git a/packages/integrations/medusajs/src/modules/resources/resources.service.ts b/packages/integrations/medusajs/src/modules/resources/resources.service.ts index 1902c2be1..f2ea07614 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'; @@ -42,11 +42,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( @@ -73,7 +73,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({ @@ -115,7 +115,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 @@ -160,7 +160,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({ @@ -190,7 +190,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/currency.ts b/packages/integrations/medusajs/src/utils/currency.ts index 19200988f..11115600c 100644 --- a/packages/integrations/medusajs/src/utils/currency.ts +++ b/packages/integrations/medusajs/src/utils/currency.ts @@ -1,11 +1,11 @@ -import { Models } from '@o2s/framework/modules'; +import { BadRequestException } from '@nestjs/common'; -const VALID_CURRENCIES: Models.Price.Currency[] = ['USD', 'EUR', 'GBP', 'PLN']; +import { Models } from '@o2s/framework/modules'; export function parseCurrency(code: string | undefined | null): Models.Price.Currency { - const normalized = (code ?? '').toUpperCase(); - if (VALID_CURRENCIES.includes(normalized as Models.Price.Currency)) { - return normalized as Models.Price.Currency; + if (typeof code !== 'string') { + throw new BadRequestException(`Invalid currency: ${code}`); } - throw new Error(`Invalid or unsupported currency: ${code}`); + + return code.toUpperCase() as Models.Price.Currency; } diff --git a/packages/integrations/medusajs/src/utils/price.ts b/packages/integrations/medusajs/src/utils/price.ts index 71a45a67f..f78a994db 100644 --- a/packages/integrations/medusajs/src/utils/price.ts +++ b/packages/integrations/medusajs/src/utils/price.ts @@ -1,3 +1,5 @@ +import { BadRequestException } from '@nestjs/common'; + import { Models } from '@o2s/framework/modules'; export function mapPriceRequired( @@ -6,7 +8,7 @@ export function mapPriceRequired( context: string, ): Models.Price.Price { if (!value) { - throw new Error(`${context}: price value is missing or invalid`); + throw new BadRequestException(`${context}: price value is missing or invalid`); } return { value, currency }; } diff --git a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts index efd610ebf..78cfce7b7 100644 --- a/packages/integrations/mocked/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/mocked/src/modules/checkout/checkout.service.ts @@ -148,7 +148,11 @@ export class CheckoutService implements Checkout.Service { return this.cartsService.getCart({ id: params.cartId }, authorization).pipe( switchMap((cart) => { if (!cart) { - return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); + 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 From d1c3f499bdfe0973a4e64e98ecd73a01849a04bc Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Tue, 17 Feb 2026 09:48:25 +0100 Subject: [PATCH 16/27] refactor(integrations.medusajs): remove unused fields and standardize type handling across payment and cart modules - Removed `type` field from `PaymentMethod` and `Cart` entities, mappers, and related services. - Standardized error handling in payment and customer address services with clearer exception usage. --- .../normalized-data-model/core-model-carts.md | 45 ++--- .../normalized-data-model/overview.md | 1 + .../src/modules/carts/carts.model.ts | 15 +- .../src/modules/carts/carts.request.ts | 5 - .../src/modules/payments/payments.model.ts | 4 +- .../src/modules/carts/carts.mapper.spec.ts | 3 +- .../src/modules/carts/carts.mapper.ts | 49 +++--- .../src/modules/carts/carts.service.ts | 30 +--- .../modules/checkout/checkout.mapper.spec.ts | 1 - .../customers/customers.service.spec.ts | 161 +++++++++++++++++- .../src/modules/payments/payments.mapper.ts | 45 ++--- .../modules/payments/payments.service.spec.ts | 98 ++++------- .../src/modules/payments/payments.service.ts | 8 +- .../mocked/src/modules/carts/carts.mapper.ts | 22 +-- .../mocked/src/modules/carts/carts.service.ts | 2 - .../modules/payments/mocks/providers.mock.ts | 16 +- .../src/modules/payments/payments.mapper.ts | 9 +- 17 files changed, 275 insertions(+), 239 deletions(-) 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 index 26e5dddd8..b3bd8ce6c 100644 --- 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 @@ -263,23 +263,21 @@ The carts model supports: ### PaymentMethod -| Field | Type | Description | -| ----------- | ----------------- | ---------------------- | -| id | string | Unique identifier | -| name | string | Display name | -| type | PaymentMethodType | Payment method type | -| description | string | Description (optional) | +| 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 | -| type | PromotionType | Percentage, fixed, or free shipping | -| value | number | Discount value | -| appliedTo | PromotionScope | CART, ITEM, or SHIPPING | +| 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 @@ -289,29 +287,14 @@ The carts model supports: | SAVED | Saved for later | | ABANDONED | Abandoned cart | -### PaymentMethodType +### PromotionType -| Value | Description | -| ------------- | ------------- | -| CREDIT_CARD | Credit card | -| PAYPAL | PayPal | -| BANK_TRANSFER | Bank transfer | -| OTHER | Other methods | - -### PromotionType / PromotionScope - -| PromotionType | Description | +| Value | Description | | ------------- | --------------------- | | PERCENTAGE | Percentage discount | | FIXED_AMOUNT | Fixed amount discount | | FREE_SHIPPING | Free shipping | -| PromotionScope | Description | -| -------------- | ---------------------- | -| CART | Applied to entire cart | -| ITEM | Applied to item | -| SHIPPING | Applied to shipping | - ### Carts Paginated list of carts. 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 55f0d1b29..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 @@ -15,6 +15,7 @@ We will cover every module that is currently supported within the O2S: - [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 diff --git a/packages/framework/src/modules/carts/carts.model.ts b/packages/framework/src/modules/carts/carts.model.ts index 3e6147418..338b0c7b9 100644 --- a/packages/framework/src/modules/carts/carts.model.ts +++ b/packages/framework/src/modules/carts/carts.model.ts @@ -3,29 +3,21 @@ import { Product } from '../products/products.model'; import { Address, Pagination, Price, Unit } from '@/utils/models'; -export type CartType = 'ACTIVE' | 'SAVED' | 'ABANDONED'; - -export type PaymentMethodType = 'CREDIT_CARD' | 'PAYPAL' | 'BANK_TRANSFER' | 'OTHER'; - export type PromotionType = 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_SHIPPING'; -export type PromotionScope = 'CART' | 'ITEM' | 'SHIPPING'; - export class PaymentMethod { id!: string; name!: string; description?: string; - type!: PaymentMethodType; } export class Promotion { id!: string; code!: string; - name!: string; + name?: string; description?: string; - type!: PromotionType; - value!: number; - appliedTo!: PromotionScope; + type?: PromotionType; + value?: string; } export class CartItem { @@ -46,7 +38,6 @@ export class Cart { id!: string; customerId?: string; name?: string; - type!: CartType; createdAt!: string; updatedAt!: string; expiresAt?: string; diff --git a/packages/framework/src/modules/carts/carts.request.ts b/packages/framework/src/modules/carts/carts.request.ts index 6da1f36a5..f12cc738b 100644 --- a/packages/framework/src/modules/carts/carts.request.ts +++ b/packages/framework/src/modules/carts/carts.request.ts @@ -1,4 +1,3 @@ -import { CartType, PaymentMethodType } from './carts.model'; import { Address, Price } from '@/utils/models'; import { PaginationQuery } from '@/utils/models/pagination'; @@ -9,7 +8,6 @@ export class GetCartParams { export class GetCartListQuery extends PaginationQuery { customerId?: string; - type?: CartType; sort?: string; locale?: string; } @@ -18,7 +16,6 @@ export class GetCartListQuery extends PaginationQuery { export class CreateCartBody { customerId?: string; name?: string; - type?: CartType; regionId?: string; currency!: Price.Currency; metadata?: Record; @@ -31,14 +28,12 @@ export class UpdateCartParams { export class UpdateCartBody { name?: string; - type?: CartType; regionId?: string; email?: string; // For guest checkout (passed directly to cart, not metadata) shippingAddressId?: string; billingAddressId?: string; shippingMethodId?: string; paymentMethodId?: string; - paymentMethodType?: PaymentMethodType; notes?: string; metadata?: Record; locale?: string; diff --git a/packages/framework/src/modules/payments/payments.model.ts b/packages/framework/src/modules/payments/payments.model.ts index 8d9847455..19c824358 100644 --- a/packages/framework/src/modules/payments/payments.model.ts +++ b/packages/framework/src/modules/payments/payments.model.ts @@ -1,11 +1,9 @@ import { Pagination } from '@/utils/models'; -export type PaymentProviderType = 'STRIPE' | 'PAYPAL' | 'ADYEN' | 'SYSTEM' | 'OTHER'; - export class PaymentProvider { id!: string; name!: string; - type!: PaymentProviderType; + type!: string; isEnabled!: boolean; requiresRedirect!: boolean; // true for redirect-based providers (Stripe Checkout) config?: Record; // Provider-specific config diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts index bcd939473..1e1fa3cc1 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.spec.ts @@ -141,7 +141,6 @@ describe('carts.mapper', () => { id: 'pm_1', name: 'Credit Card', description: 'Pay with card', - type: 'CREDIT_CARD', }, }, }); @@ -149,7 +148,7 @@ describe('carts.mapper', () => { expect(result.paymentMethod).toBeDefined(); expect(result.paymentMethod?.id).toBe('pm_1'); expect(result.paymentMethod?.name).toBe('Credit Card'); - expect(result.paymentMethod?.type).toBe('CREDIT_CARD'); + expect(result.paymentMethod?.description).toBe('Pay with card'); }); it('should map promotions when present', () => { diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index fbbfc9ee0..ca3a04efa 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -27,7 +27,6 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca id: cart.id, customerId: cart.customer_id ?? undefined, name: undefined, // Medusa doesn't have cart names by default - type: 'ACTIVE', // Medusa carts are active by default createdAt: cart.created_at?.toString() ?? new Date().toISOString(), updatedAt: cart.updated_at?.toString() ?? new Date().toISOString(), expiresAt: undefined, // Medusa handles expiration differently @@ -106,8 +105,6 @@ const mapAddress = (address?: HttpTypes.StoreCartAddress | null): Models.Address }; }; -const VALID_PAYMENT_METHOD_TYPES: Carts.Model.PaymentMethodType[] = ['CREDIT_CARD', 'PAYPAL', 'BANK_TRANSFER', 'OTHER']; - const mapPaymentMethodFromMetadata = (metadata: Record): Carts.Model.PaymentMethod | undefined => { const stored = metadata?.paymentMethod; if (stored === null || stored === undefined || typeof stored !== 'object' || Array.isArray(stored)) @@ -119,17 +116,11 @@ const mapPaymentMethodFromMetadata = (metadata: Record): Carts. if (typeof id !== 'string' || typeof name !== 'string') return undefined; const description = storedObj.description; - const typeVal = storedObj.type; - const type: Carts.Model.PaymentMethodType = - typeof typeVal === 'string' && VALID_PAYMENT_METHOD_TYPES.includes(typeVal as Carts.Model.PaymentMethodType) - ? (typeVal as Carts.Model.PaymentMethodType) - : 'OTHER'; return { id, name, description: typeof description === 'string' ? description : undefined, - type, }; }; @@ -147,24 +138,32 @@ const mapShippingMethod = ( }; const mapPromotions = (cart: HttpTypes.StoreCart): Carts.Model.Promotion[] | undefined => { - // Medusa v2 uses promotions differently - map from adjustments if available - const promotions: Carts.Model.Promotion[] = []; - - // Extract promotion codes from cart if available - if (cart.promotions && Array.isArray(cart.promotions)) { - for (const promo of cart.promotions) { - promotions.push({ - id: promo.id ?? '', - code: promo.code ?? '', - name: promo.code ?? '', - description: undefined, - type: 'PERCENTAGE', // Default type - value: 0, - appliedTo: 'CART', - }); - } + 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.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 3b1a5781d..556562e0e 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -135,9 +135,7 @@ export class CartsService extends Carts.Service { } deleteCart(_params: Carts.Request.DeleteCartParams, _authorization?: string): Observable { - this.logger.debug('Delete cart operation - not directly supported in Medusa Store API'); - - return of(undefined as void); + return throwError(() => new NotImplementedException('Delete cart is not supported in Medusa.js Store API.')); } addCartItem(data: Carts.Request.AddCartItemBody, authorization?: string): Observable { @@ -484,32 +482,6 @@ export class CartsService extends Carts.Service { ); } - /** - * Resolves the billing address from the request data. - * Returns `null` if no billing address is specified (caller should fall back to shipping address). - */ - private resolveBillingAddress( - data: Carts.Request.UpdateCartAddressesBody, - authorization?: string, - ): Observable { - if (data.billingAddressId && authorization) { - return this.customersService.getAddress({ id: data.billingAddressId }, authorization).pipe( - map((billingAddress) => { - if (!billingAddress) { - throw new NotFoundException(`Address with ID ${data.billingAddressId} not found`); - } - return mapAddressToMedusa(billingAddress.address); - }), - ); - } - - if (data.billingAddress) { - return of(mapAddressToMedusa(data.billingAddress)); - } - - return of(null); - } - /** * Builds cart metadata immutably by merging optional notes * into existing metadata without mutating any arguments. diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts index 9bb1911bc..36a943a21 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.mapper.spec.ts @@ -30,7 +30,6 @@ function minimalCartWithAllFields(): Carts.Model.Cart { const paymentMethod: Carts.Model.PaymentMethod = { id: 'pm_1', name: 'Card', - type: 'OTHER', }; return { id: 'cart_1', diff --git a/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts index cc29fc2f3..edbfa03fd 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts @@ -1,5 +1,5 @@ import { HttpTypes } from '@medusajs/types'; -import { UnauthorizedException } from '@nestjs/common'; +import { InternalServerErrorException, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { firstValueFrom } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -69,15 +69,17 @@ describe('CustomersService', () => { }); describe('getAddresses', () => { - it('should throw UnauthorizedException when auth is missing', () => { - expect(() => service.getAddresses(undefined)).toThrow(UnauthorizedException); - expect(() => service.getAddresses(undefined)).toThrow('Authentication required'); + 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', () => { + it('should throw UnauthorizedException when getCustomerId returns undefined', async () => { mockAuthService.getCustomerId.mockReturnValue(undefined); - expect(() => service.getAddresses('Bearer token')).toThrow(UnauthorizedException); - expect(() => service.getAddresses('Bearer token')).toThrow('Invalid authentication'); + 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 () => { @@ -238,6 +240,44 @@ describe('CustomersService', () => { ); 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', () => { @@ -269,6 +309,47 @@ describe('CustomersService', () => { ); 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', () => { @@ -288,9 +369,37 @@ describe('CustomersService', () => { 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', () => { + expect(() => + service.setDefaultAddress({ id: 'addr_1' } as Customers.Request.SetDefaultAddressParams, undefined), + ).toThrow(UnauthorizedException); + }); + it('should call updateAddress with is_default_shipping and return mapped address', async () => { mockAuthService.getCustomerId.mockReturnValue('cust_1'); mockSdk.store.customer.updateAddress.mockResolvedValue({ @@ -313,5 +422,43 @@ describe('CustomersService', () => { 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/payments/payments.mapper.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts index 85878cef4..21e1bbdc5 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts @@ -2,13 +2,25 @@ import { HttpTypes } from '@medusajs/types'; import { Payments } from '@o2s/framework/modules'; -export function mapPaymentProvider(medusaProvider: HttpTypes.StorePaymentProvider): Payments.Model.PaymentProvider { +export function mapPaymentProvider(provider: HttpTypes.StorePaymentProvider): Payments.Model.PaymentProvider { + const idLower = provider.id.toLowerCase(); + let type = 'OTHER'; + if (idLower.includes('stripe')) { + type = 'STRIPE'; + } else if (idLower.includes('paypal')) { + type = 'PAYPAL'; + } else if (idLower.includes('adyen')) { + type = 'ADYEN'; + } else if (idLower.includes('system') || idLower.includes('manual')) { + type = 'SYSTEM'; + } + return { - id: medusaProvider.id, - name: medusaProvider.id, // Medusa doesn't provide a name, use ID - type: mapProviderType(medusaProvider.id), + id: provider.id, + name: provider.id, // Medusa doesn't provide a name, use ID + type, isEnabled: true, // Assume enabled if returned by API - requiresRedirect: medusaProvider.id.includes('stripe') || medusaProvider.id.includes('paypal'), + requiresRedirect: provider.id.includes('stripe') || provider.id.includes('paypal'), config: {}, }; } @@ -25,30 +37,21 @@ export function mapPaymentProviders( } export function mapPaymentSession( - medusaSession: HttpTypes.StorePaymentSession, + session: HttpTypes.StorePaymentSession, cartId: string, ): Payments.Model.PaymentSession { return { - id: medusaSession.id, + id: session.id, cartId, - providerId: medusaSession.provider_id, - status: mapPaymentSessionStatus(medusaSession.status), - redirectUrl: medusaSession.data?.redirect_url as string | undefined, - clientSecret: medusaSession.data?.client_secret as string | undefined, + 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: medusaSession.data as Record | undefined, + metadata: session.data as Record | undefined, }; } -function mapProviderType(providerId: string): Payments.Model.PaymentProviderType { - const id = providerId.toLowerCase(); - if (id.includes('stripe')) return 'STRIPE'; - if (id.includes('paypal')) return 'PAYPAL'; - if (id.includes('adyen')) return 'ADYEN'; - if (id.includes('system') || id.includes('manual')) return 'SYSTEM'; - return 'OTHER'; -} - function mapPaymentSessionStatus(status: string): Payments.Model.PaymentSessionStatus { switch (status.toUpperCase()) { case 'AUTHORIZED': diff --git a/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts b/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts index d4bba3055..0773a6ca5 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.spec.ts @@ -1,6 +1,11 @@ import { HttpTypes } from '@medusajs/types'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { firstValueFrom, of, throwError } from 'rxjs'; +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'; @@ -73,13 +78,13 @@ describe('PaymentsService', () => { }); describe('getProviders', () => { - it('should throw BadRequestException when regionId is missing', () => { - expect(() => service.getProviders({} as Payments.Request.GetProvidersParams, 'Bearer token')).toThrow( - BadRequestException, - ); - expect(() => service.getProviders({} as Payments.Request.GetProvidersParams, 'Bearer token')).toThrow( - 'regionId is required', - ); + 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 () => { @@ -169,12 +174,20 @@ describe('PaymentsService', () => { expect(result.providerId).toBe('pp_manual'); }); - it('should throw when sessions array is empty', async () => { + 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( @@ -200,73 +213,32 @@ describe('PaymentsService', () => { }); describe('getSession', () => { - it('should return of(undefined)', async () => { - const result = await firstValueFrom( - service.getSession({ id: 'ps_1' } as Payments.Request.GetSessionParams, 'Bearer token'), - ); - expect(result).toBeUndefined(); + 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 post to payment-sessions and return mapped session', async () => { - mockHttpService.post.mockReturnValue( - of({ data: { payment_session: { ...minimalPaymentSession, id: 'ps_updated' } } }), - ); - - const result = await firstValueFrom( - service.updateSession( - { id: 'ps_1' } as Payments.Request.UpdateSessionParams, - { returnUrl: 'https://example.com/return' } as Payments.Request.UpdateSessionBody, - 'Bearer token', - ), - ); - - expect(mockHttpService.post).toHaveBeenCalledWith( - `${BASE_URL}/store/payment-sessions/ps_1`, - { return_url: 'https://example.com/return' }, - expect.objectContaining({ headers: expect.any(Object) }), - ); - expect(result.id).toBe('ps_updated'); - }); - - it('should throw NotFoundException on 404', async () => { - mockHttpService.post.mockReturnValue(throwError(() => ({ response: { status: 404 } }))); - + it('should throw NotImplementedException', async () => { await expect( firstValueFrom( service.updateSession( - { id: 'missing' } as Payments.Request.UpdateSessionParams, - {} as Payments.Request.UpdateSessionBody, + { id: 'ps_1' } as Payments.Request.UpdateSessionParams, + { returnUrl: 'https://example.com/return' } as Payments.Request.UpdateSessionBody, 'Bearer token', ), ), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow(NotImplementedException); }); }); describe('cancelSession', () => { - it('should delete payment-sessions and return void', async () => { - mockHttpService.delete.mockReturnValue(of({})); - - await firstValueFrom( - service.cancelSession({ id: 'ps_1' } as Payments.Request.CancelSessionParams, 'Bearer token'), - ); - - expect(mockHttpService.delete).toHaveBeenCalledWith( - `${BASE_URL}/store/payment-sessions/ps_1`, - expect.objectContaining({ headers: expect.any(Object) }), - ); - }); - - it('should throw NotFoundException on 404', async () => { - mockHttpService.delete.mockReturnValue(throwError(() => ({ response: { status: 404 } }))); - - await expect( - firstValueFrom( - service.cancelSession({ id: 'missing' } as Payments.Request.CancelSessionParams, 'Bearer token'), - ), - ).rejects.toThrow(NotFoundException); + 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 index 2cb1e0952..a0d273dfd 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.service.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.service.ts @@ -69,7 +69,9 @@ export class PaymentsService extends Payments.Service { return mapPaymentProviders(providers); }), catchError((error) => { - // If endpoint doesn't exist or fails, return empty list + 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([])); }), @@ -130,7 +132,7 @@ export class PaymentsService extends Payments.Service { _params: Payments.Request.GetSessionParams, _authorization: string | undefined, ): Observable { - throw new NotImplementedException(); + return throwError(() => new NotImplementedException()); } /** @@ -143,7 +145,7 @@ export class PaymentsService extends Payments.Service { _data: Payments.Request.UpdateSessionBody, _authorization: string | undefined, ): Observable { - throw new NotImplementedException(); + return throwError(() => new NotImplementedException()); } /** diff --git a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts index 88cb83efd..1700f08d2 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.mapper.ts @@ -12,7 +12,6 @@ const mapPaymentMethodFromMetadata = (metadata: Record): Carts. id: stored.id as string, name: stored.name as string, description: (stored.description as string) ?? undefined, - type: (stored.type as Carts.Model.PaymentMethodType) ?? 'OTHER', }; }; @@ -24,8 +23,7 @@ const PROMOTIONS: Carts.Model.Promotion[] = [ name: '10% Off', description: 'Get 10% off your order', type: 'PERCENTAGE', - value: 10, - appliedTo: 'CART', + value: '10', }, { id: 'PROMO-002', @@ -33,8 +31,7 @@ const PROMOTIONS: Carts.Model.Promotion[] = [ name: 'Free Shipping', description: 'Free standard shipping', type: 'FREE_SHIPPING', - value: 0, - appliedTo: 'SHIPPING', + value: '0', }, ]; @@ -94,11 +91,10 @@ export const mapCart = (params: Carts.Request.GetCartParams): Carts.Model.Cart | // Get cart list with filters export const mapCarts = (query: Carts.Request.GetCartListQuery, customerId?: string): Carts.Model.Carts => { - const { offset = 0, limit = 10, type, sort } = query; + const { offset = 0, limit = 10, sort } = query; let filteredCarts = cartsStore.filter((cart) => { if (customerId && cart.customerId !== customerId) return false; - if (type && cart.type !== type) return false; return true; }); @@ -135,7 +131,6 @@ export const createCart = (data: Carts.Request.CreateCartBody): Carts.Model.Cart id: newId, customerId: data.customerId, name: data.name, - type: data.type || 'ACTIVE', createdAt: formatDate(now), updatedAt: formatDate(now), expiresAt: formatDate(expiresAt), @@ -200,7 +195,6 @@ export const updateCart = ( const updatedCart: Carts.Model.Cart = { ...cart, name: data.name ?? cart.name, - type: data.type ?? cart.type, regionId: data.regionId ?? cart.regionId, email: data.email ?? cart.email, notes: data.notes ?? cart.notes, @@ -230,7 +224,7 @@ export const deleteCart = (params: Carts.Request.DeleteCartParams): boolean => { // 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 && cart.type === 'ACTIVE'); + return cartsStore.find((cart) => cart.customerId === customerId); }; const matchesSku = (item: Carts.Model.CartItem, sku: string): boolean => item.sku === sku; @@ -385,10 +379,10 @@ const recalculateCartTotals = (cart: Carts.Model.Cart): void => { let discountTotal = 0; if (cart.promotions) { for (const promo of cart.promotions) { - if (promo.type === 'PERCENTAGE' && promo.appliedTo === 'CART') { - discountTotal += (subtotal * promo.value) / 100; - } else if (promo.type === 'FIXED_AMOUNT' && promo.appliedTo === 'CART') { - discountTotal += promo.value; + if (promo.type === 'PERCENTAGE') { + discountTotal += (subtotal * Number(promo.value)) / 100; + } else if (promo.type === 'FIXED_AMOUNT') { + discountTotal += Number(promo.value); } } } diff --git a/packages/integrations/mocked/src/modules/carts/carts.service.ts b/packages/integrations/mocked/src/modules/carts/carts.service.ts index 8f1f28a49..7dc154bf5 100644 --- a/packages/integrations/mocked/src/modules/carts/carts.service.ts +++ b/packages/integrations/mocked/src/modules/carts/carts.service.ts @@ -172,7 +172,6 @@ export class CartsService implements Carts.Service { } cart = createCart({ customerId, - type: 'ACTIVE', currency: data.currency, regionId: data.regionId, }); @@ -185,7 +184,6 @@ export class CartsService implements Carts.Service { cart = createCart({ currency: data.currency, regionId: data.regionId, - type: 'ACTIVE', }); } diff --git a/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts b/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts index 20c188a94..8d880aa1b 100644 --- a/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts +++ b/packages/integrations/mocked/src/modules/payments/mocks/providers.mock.ts @@ -2,17 +2,7 @@ import { Payments } from '@o2s/framework/modules'; type Locale = 'en' | 'de' | 'pl'; -const PROVIDERS_BY_LOCALE: Record< - Locale, - Array<{ - id: string; - name: string; - type: Payments.Model.PaymentProviderType; - isEnabled: boolean; - requiresRedirect: boolean; - config?: Record; - }> -> = { +const PROVIDERS_BY_LOCALE: Record> = { en: [ { id: 'stripe', @@ -74,9 +64,7 @@ export function getMockProviderById(id: string, locale?: string): Payments.Model } /** Map provider type to cart PaymentMethodType. */ -const providerTypeToPaymentMethodType = ( - t: Payments.Model.PaymentProviderType, -): 'CREDIT_CARD' | 'PAYPAL' | 'BANK_TRANSFER' | 'OTHER' => { +const providerTypeToPaymentMethodType = (t: string): 'CREDIT_CARD' | 'PAYPAL' | 'BANK_TRANSFER' | 'OTHER' => { if (t === 'PAYPAL') return 'PAYPAL'; if (t === 'STRIPE') return 'CREDIT_CARD'; return 'OTHER'; diff --git a/packages/integrations/mocked/src/modules/payments/payments.mapper.ts b/packages/integrations/mocked/src/modules/payments/payments.mapper.ts index 01f18b382..149ffa357 100644 --- a/packages/integrations/mocked/src/modules/payments/payments.mapper.ts +++ b/packages/integrations/mocked/src/modules/payments/payments.mapper.ts @@ -1,15 +1,10 @@ import { Payments } from '@o2s/framework/modules'; -export function mapPaymentProviders( - providers: Payments.Model.PaymentProvider[], - limit = 10, - offset = 0, -): Payments.Model.PaymentProviders { +export function mapPaymentProviders(providers: Payments.Model.PaymentProvider[]): Payments.Model.PaymentProviders { const total = providers.length; - const paginatedProviders = providers.slice(offset, offset + limit); return { - data: paginatedProviders, + data: providers, total, }; } From fc13c1a8b67717a56a3380f555c4baf08351842d Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Tue, 17 Feb 2026 10:14:56 +0100 Subject: [PATCH 17/27] refactor(integrations.medusajs): standardize error handling and exception usage in services --- .gitignore | 3 + .../core-model-payments.md | 154 +++++ package.json | 1 + .../customers/customers.service.spec.ts | 148 +++- .../modules/customers/customers.service.ts | 26 +- .../modules/payments/payments.mapper.spec.ts | 27 +- .../src/modules/payments/payments.mapper.ts | 16 +- scripts/generate-postman-collection.mjs | 633 ++++++++++++++++++ 8 files changed, 931 insertions(+), 77 deletions(-) create mode 100644 apps/docs/docs/main-components/harmonization-app/normalized-data-model/core-model-payments.md create mode 100644 scripts/generate-postman-collection.mjs 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/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/package.json b/package.json index 3ba4ee2db..e339e4a74 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/integrations/medusajs/src/modules/customers/customers.service.spec.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts index edbfa03fd..a078635e1 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.spec.ts @@ -108,17 +108,27 @@ describe('CustomersService', () => { }); describe('getAddress', () => { - it('should throw UnauthorizedException when auth is missing', () => { - expect(() => service.getAddress({ id: 'addr_1' } as Customers.Request.GetAddressParams, undefined)).toThrow( - UnauthorizedException, - ); + 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', () => { + it('should throw UnauthorizedException when getCustomerId returns undefined', async () => { mockAuthService.getCustomerId.mockReturnValue(undefined); - expect(() => - service.getAddress({ id: 'addr_1' } as Customers.Request.GetAddressParams, 'Bearer token'), - ).toThrow(UnauthorizedException); + 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 () => { @@ -149,10 +159,31 @@ describe('CustomersService', () => { }); describe('createAddress', () => { - it('should throw UnauthorizedException when auth is missing', () => { - expect(() => - service.createAddress({ address: {} } as Customers.Request.CreateAddressBody, undefined), - ).toThrow(UnauthorizedException); + 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 () => { @@ -281,10 +312,31 @@ describe('CustomersService', () => { }); describe('updateAddress', () => { - it('should throw UnauthorizedException when auth is missing', () => { - expect(() => - service.updateAddress({ id: 'addr_1' }, {} as Customers.Request.UpdateAddressBody, undefined), - ).toThrow(UnauthorizedException); + 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 () => { @@ -353,10 +405,31 @@ describe('CustomersService', () => { }); describe('deleteAddress', () => { - it('should throw UnauthorizedException when auth is missing', () => { - expect(() => - service.deleteAddress({ id: 'addr_1' } as Customers.Request.DeleteAddressParams, undefined), - ).toThrow(UnauthorizedException); + 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 () => { @@ -394,10 +467,37 @@ describe('CustomersService', () => { }); describe('setDefaultAddress', () => { - it('should throw UnauthorizedException when auth is missing', () => { - expect(() => - service.setDefaultAddress({ id: 'addr_1' } as Customers.Request.SetDefaultAddressParams, undefined), - ).toThrow(UnauthorizedException); + 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 () => { diff --git a/packages/integrations/medusajs/src/modules/customers/customers.service.ts b/packages/integrations/medusajs/src/modules/customers/customers.service.ts index 629216de3..ab685101a 100644 --- a/packages/integrations/medusajs/src/modules/customers/customers.service.ts +++ b/packages/integrations/medusajs/src/modules/customers/customers.service.ts @@ -69,12 +69,12 @@ export class CustomersService extends Customers.Service { authorization: string | undefined, ): Observable { if (!authorization) { - throw new UnauthorizedException('Authentication required'); + return throwError(() => new UnauthorizedException('Authentication required')); } const customerId = this.authService.getCustomerId(authorization); if (!customerId) { - throw new UnauthorizedException('Invalid authentication'); + return throwError(() => new UnauthorizedException('Invalid authentication')); } return from( @@ -104,12 +104,12 @@ export class CustomersService extends Customers.Service { authorization: string | undefined, ): Observable { if (!authorization) { - throw new UnauthorizedException('Authentication required'); + return throwError(() => new UnauthorizedException('Authentication required')); } const customerId = this.authService.getCustomerId(authorization); if (!customerId) { - throw new UnauthorizedException('Invalid authentication'); + return throwError(() => new UnauthorizedException('Invalid authentication')); } const medusaAddress = mapAddressToMedusa(data.address); @@ -173,12 +173,12 @@ export class CustomersService extends Customers.Service { authorization: string | undefined, ): Observable { if (!authorization) { - throw new UnauthorizedException('Authentication required'); + return throwError(() => new UnauthorizedException('Authentication required')); } const customerId = this.authService.getCustomerId(authorization); if (!customerId) { - throw new UnauthorizedException('Invalid authentication'); + return throwError(() => new UnauthorizedException('Invalid authentication')); } const updateData = data.address ? mapAddressToMedusa(data.address) : {}; @@ -222,12 +222,12 @@ export class CustomersService extends Customers.Service { deleteAddress(params: Customers.Request.DeleteAddressParams, authorization: string | undefined): Observable { if (!authorization) { - throw new UnauthorizedException('Authentication required'); + return throwError(() => new UnauthorizedException('Authentication required')); } const customerId = this.authService.getCustomerId(authorization); if (!customerId) { - throw new UnauthorizedException('Invalid authentication'); + return throwError(() => new UnauthorizedException('Invalid authentication')); } return from( @@ -251,12 +251,12 @@ export class CustomersService extends Customers.Service { authorization: string | undefined, ): Observable { if (!authorization) { - throw new UnauthorizedException('Authentication required'); + return throwError(() => new UnauthorizedException('Authentication required')); } const customerId = this.authService.getCustomerId(authorization); if (!customerId) { - throw new UnauthorizedException('Invalid authentication'); + return throwError(() => new UnauthorizedException('Invalid authentication')); } return from( @@ -270,14 +270,14 @@ export class CustomersService extends Customers.Service { this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( - map((response: HttpTypes.StoreCustomerResponse) => { + switchMap((response: HttpTypes.StoreCustomerResponse) => { const customer = response.customer; const addresses = customer.addresses || []; const updatedAddress = addresses.find((a) => a.id === params.id); if (!updatedAddress) { - throw new NotFoundException(`Address with ID ${params.id} not found`); + return throwError(() => new NotFoundException(`Address with ID ${params.id} not found`)); } - return mapCustomerAddress(updatedAddress, customerId); + return of(mapCustomerAddress(updatedAddress, customerId)); }), catchError((error) => { if (error.response?.status === 404) { diff --git a/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts index b6da76826..8c2fd88cc 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.spec.ts @@ -9,35 +9,10 @@ describe('payments.mapper', () => { const provider = { id: 'pp_stripe_123' } as HttpTypes.StorePaymentProvider; const result = mapPaymentProvider(provider); expect(result.id).toBe('pp_stripe_123'); - expect(result.type).toBe('STRIPE'); + expect(result.type).toBe('pp_stripe_123'); expect(result.name).toBe('pp_stripe_123'); }); - it('should map type STRIPE when id includes stripe', () => { - const result = mapPaymentProvider({ id: 'pp_stripe_default' } as HttpTypes.StorePaymentProvider); - expect(result.type).toBe('STRIPE'); - }); - - it('should map type PAYPAL when id includes paypal', () => { - const result = mapPaymentProvider({ id: 'pp_paypal_express' } as HttpTypes.StorePaymentProvider); - expect(result.type).toBe('PAYPAL'); - }); - - it('should map type ADYEN when id includes adyen', () => { - const result = mapPaymentProvider({ id: 'pp_adyen_checkout' } as HttpTypes.StorePaymentProvider); - expect(result.type).toBe('ADYEN'); - }); - - it('should map type SYSTEM when id includes system or manual', () => { - expect(mapPaymentProvider({ id: 'pp_system' } as HttpTypes.StorePaymentProvider).type).toBe('SYSTEM'); - expect(mapPaymentProvider({ id: 'pp_manual' } as HttpTypes.StorePaymentProvider).type).toBe('SYSTEM'); - }); - - it('should map type OTHER for unknown provider', () => { - const result = mapPaymentProvider({ id: 'pp_custom_gateway' } as HttpTypes.StorePaymentProvider); - expect(result.type).toBe('OTHER'); - }); - it('should set requiresRedirect for stripe and paypal', () => { expect(mapPaymentProvider({ id: 'pp_stripe' } as HttpTypes.StorePaymentProvider).requiresRedirect).toBe( true, diff --git a/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts index 21e1bbdc5..1838c2d4a 100644 --- a/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts +++ b/packages/integrations/medusajs/src/modules/payments/payments.mapper.ts @@ -3,22 +3,10 @@ import { HttpTypes } from '@medusajs/types'; import { Payments } from '@o2s/framework/modules'; export function mapPaymentProvider(provider: HttpTypes.StorePaymentProvider): Payments.Model.PaymentProvider { - const idLower = provider.id.toLowerCase(); - let type = 'OTHER'; - if (idLower.includes('stripe')) { - type = 'STRIPE'; - } else if (idLower.includes('paypal')) { - type = 'PAYPAL'; - } else if (idLower.includes('adyen')) { - type = 'ADYEN'; - } else if (idLower.includes('system') || idLower.includes('manual')) { - type = 'SYSTEM'; - } - return { id: provider.id, - name: provider.id, // Medusa doesn't provide a name, use ID - type, + 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: {}, diff --git a/scripts/generate-postman-collection.mjs b/scripts/generate-postman-collection.mjs new file mode 100644 index 000000000..acabf8cce --- /dev/null +++ b/scripts/generate-postman-collection.mjs @@ -0,0 +1,633 @@ +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); + } + }); + + const url = { + raw: `{{baseUrl}}/${urlPath.join('/')}`.replace(/\/+/g, '/'), + host: ['{{baseUrl}}'], + path: urlPath, + query: query.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'); From c8fb00c30d48c8b9598b0cd2d5b8d51ff70486d5 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Tue, 17 Feb 2026 13:44:20 +0100 Subject: [PATCH 18/27] refactor(integrations.medusajs): centralize `mapAddress` and enhance price mapping for consistent error handling and field validation --- .../src/modules/carts/carts.mapper.ts | 54 +++++-------------- .../src/modules/carts/carts.service.ts | 40 ++++++++++---- .../src/modules/checkout/checkout.service.ts | 5 ++ .../src/modules/orders/orders.mapper.ts | 50 +++++------------ .../src/modules/resources/resources.mapper.ts | 24 +-------- .../medusajs/src/utils/address.ts | 40 ++++++++++++++ .../integrations/medusajs/src/utils/price.ts | 17 +++++- 7 files changed, 119 insertions(+), 111 deletions(-) create mode 100644 packages/integrations/medusajs/src/utils/address.ts diff --git a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts index ca3a04efa..d54191983 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.mapper.ts @@ -3,9 +3,10 @@ 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 { mapPriceRequired } from '@/utils/price'; +import { mapPrice } from '@/utils/price'; export const mapCarts = ( carts: { carts: HttpTypes.StoreCart[]; count?: number }, @@ -36,11 +37,11 @@ export const mapCart = (cart: HttpTypes.StoreCart, _defaultCurrency: string): Ca data: cart.items?.map((item) => mapCartItem(item, currency)) ?? [], total: cart.items?.length ?? 0, }, - subtotal: mapPrice(cart.subtotal, currency), - discountTotal: mapPrice(cart.discount_total, currency), - taxTotal: mapPrice(cart.tax_total, currency), - shippingTotal: mapPrice(cart.shipping_total, currency), - total: mapPriceRequired(cart.total, currency, `Cart ${cart.id} total`), + 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, @@ -57,10 +58,10 @@ const mapCartItem = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.C id: item.id, sku: item.variant_sku ?? item.variant_id ?? '', quantity: item.quantity, - price: mapPriceRequired(item.unit_price, currency, `Cart item ${item.id} unit_price`), - subtotal: mapPrice(item.subtotal, currency), - discountTotal: mapPrice(item.discount_total, currency), - total: mapPriceRequired(item.total, currency, `Cart item ${item.id} total`), + 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), @@ -81,7 +82,7 @@ const mapProduct = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.Cu alt: item.product_title ?? item.title ?? '', } : undefined, - price: mapPriceRequired(item.unit_price, currency, `Cart product ${item.product_id} unit_price`), + price: mapPrice(item.unit_price, currency, `Cart product ${item.product_id} unit_price`), link: '', type: 'PHYSICAL', category: '', @@ -89,22 +90,6 @@ const mapProduct = (item: HttpTypes.StoreCartLineItem, currency: Models.Price.Cu }; }; -const mapAddress = (address?: HttpTypes.StoreCartAddress | null): Models.Address.Address | undefined => { - if (!address) return undefined; - return { - firstName: address.first_name, - lastName: address.last_name, - country: address.country_code ?? '', - district: address.province ?? '', - region: address.province ?? '', - streetName: address.address_1 ?? '', - apartment: address.address_2 ?? '', - city: address.city ?? '', - postalCode: address.postal_code ?? '', - phone: address.phone ?? '', - }; -}; - const mapPaymentMethodFromMetadata = (metadata: Record): Carts.Model.PaymentMethod | undefined => { const stored = metadata?.paymentMethod; if (stored === null || stored === undefined || typeof stored !== 'object' || Array.isArray(stored)) @@ -132,8 +117,8 @@ const mapShippingMethod = ( id: method.id, name: method.name ?? '', description: method.description ?? '', - total: mapPrice(method.total, currency), - subtotal: mapPrice(method.subtotal, currency), + total: mapPrice(method.total, currency, `Cart shipping method ${method.id} total`), + subtotal: mapPrice(method.subtotal, currency, `Cart shipping method ${method.id} subtotal`), }; }; @@ -166,14 +151,3 @@ const mapPromotions = (cart: HttpTypes.StoreCart): Carts.Model.Promotion[] | und return promotions.length > 0 ? promotions : undefined; }; - -const mapPrice = ( - value: number | undefined | null, - currency: Models.Price.Currency, -): Models.Price.Price | undefined => { - if (typeof value === 'undefined' || value === null) return undefined; - return { - value, - currency, - }; -}; diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 556562e0e..7844a1360 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -28,6 +28,8 @@ export class CartsService extends Carts.Service { private readonly sdk: Medusa; private readonly defaultCurrency: string; + private readonly cartItemsFields = '*items'; + constructor( private readonly config: ConfigService, @Inject(LoggerService) protected readonly logger: LoggerService, @@ -46,7 +48,11 @@ export class CartsService extends Carts.Service { getCart(params: Carts.Request.GetCartParams, authorization?: string): Observable { return from( - this.sdk.store.cart.retrieve(params.id, {}, this.medusaJsService.getStoreApiHeaders(authorization)), + 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); @@ -88,7 +94,7 @@ export class CartsService extends Carts.Service { region_id: data.regionId, metadata: data.metadata, }, - {}, + { fields: this.cartItemsFields }, this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( @@ -125,7 +131,7 @@ export class CartsService extends Carts.Service { this.sdk.store.cart.update( params.id, cartUpdate, - {}, + { fields: this.cartItemsFields }, this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( @@ -149,7 +155,11 @@ export class CartsService extends Carts.Service { if (data.cartId) { const cartId = data.cartId; return from( - this.sdk.store.cart.retrieve(cartId, {}, this.medusaJsService.getStoreApiHeaders(authorization)), + 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); @@ -166,7 +176,7 @@ export class CartsService extends Carts.Service { quantity: data.quantity, metadata: data.metadata, }, - {}, + { fields: this.cartItemsFields }, this.medusaJsService.getStoreApiHeaders(authorization), ), ); @@ -203,7 +213,7 @@ export class CartsService extends Carts.Service { quantity: data.quantity, metadata: data.metadata, }, - {}, + { fields: this.cartItemsFields }, this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( @@ -217,6 +227,7 @@ export class CartsService extends Carts.Service { this.sdk.store.cart.deleteLineItem( params.cartId, params.itemId, + { fields: this.cartItemsFields }, this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( @@ -245,6 +256,7 @@ export class CartsService extends Carts.Service { body: { promo_codes: [data.code], }, + query: { fields: this.cartItemsFields }, }), ).pipe( map((response) => mapCart(response.cart, this.defaultCurrency)), @@ -263,6 +275,7 @@ export class CartsService extends Carts.Service { body: { promo_codes: [params.code], }, + query: { fields: this.cartItemsFields }, }), ).pipe( map((response) => mapCart(response.cart, this.defaultCurrency)), @@ -321,7 +334,7 @@ export class CartsService extends Carts.Service { region_id: regionId, metadata, }, - {}, + { fields: this.cartItemsFields }, this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( @@ -334,7 +347,7 @@ export class CartsService extends Carts.Service { quantity, metadata, }, - {}, + { fields: this.cartItemsFields }, this.medusaJsService.getStoreApiHeaders(authorization), ), ), @@ -422,7 +435,14 @@ export class CartsService extends Carts.Service { } // Update cart - return from(this.sdk.store.cart.update(params.cartId, cartUpdate, {}, headers)).pipe( + 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) { @@ -465,7 +485,7 @@ export class CartsService extends Carts.Service { this.sdk.store.cart.addShippingMethod( params.cartId, { option_id: data.shippingOptionId }, - {}, + { fields: this.cartItemsFields }, headers, ), ).pipe( diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts index 0bd22ab5c..82a138a5a 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts @@ -150,6 +150,11 @@ export class CheckoutService extends Checkout.Service { if (paymentSessionId) { return this.paymentsService .getSession({ id: paymentSessionId }, authorization) + .pipe( + catchError(() => { + return of(undefined); + }), + ) .pipe(map((session) => mapCheckoutSummary(cart, session))); } diff --git a/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts b/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts index 8a7634685..952d48d86 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.mapper.ts @@ -3,8 +3,9 @@ import { NotFoundException } from '@nestjs/common'; import { Models, Orders, Products } from '@o2s/framework/modules'; +import { mapAddress } from '@/utils/address'; import { parseCurrency } from '@/utils/currency'; -import { mapPriceRequired } from '@/utils/price'; +import { mapPrice } from '@/utils/price'; export const mapOrders = (orders: HttpTypes.StoreOrderListResponse, defaultCurrency: string): Orders.Model.Orders => { return { @@ -18,11 +19,11 @@ export const mapOrder = (order: HttpTypes.StoreOrder, defaultCurrency: string): return { id: order.id, - total: mapPriceRequired(order.total, currency, `Order ${order.id} total`), - subtotal: mapPrice(order.subtotal, currency), - shippingTotal: mapPrice(order.shipping_total, currency), - discountTotal: mapPrice(order.discount_total, currency), - tax: mapPrice(order.tax_total, 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), @@ -46,9 +47,9 @@ const mapOrderItem = (item: HttpTypes.StoreOrderLineItem, currency: Models.Price id: item.id, productId: item.variant_id || '', quantity: item.quantity, - price: mapPriceRequired(item.unit_price, currency, `Order item ${item.id} unit_price`), - total: mapPrice(item.total, currency), - subtotal: mapPrice(item.subtotal, currency), + 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), }; @@ -73,7 +74,7 @@ const mapProduct = ( alt: item.product_title || item.title, } : undefined, - price: mapPriceRequired(unitPrice, currency, `Order product ${item.product_id} unit_price`), + price: mapPrice(unitPrice, currency, `Order product ${item.product_id} unit_price`), link: '', type: 'PHYSICAL', category: item.product?.categories?.[0]?.name || '', @@ -81,23 +82,6 @@ const mapProduct = ( }; }; -const mapAddress = (address?: HttpTypes.StoreOrderAddress | null): Models.Address.Address | undefined => { - if (!address) return undefined; - return { - firstName: address.first_name, - lastName: address.last_name, - 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.StoreOrderShippingMethod, currency: Models.Price.Currency, @@ -106,16 +90,8 @@ const mapShippingMethod = ( id: method.id, name: method.name || '', description: method.description || '', - total: mapPrice(method.total, currency), - subtotal: mapPrice(method.subtotal, currency), - }; -}; - -const mapPrice = (value: number, currency: Models.Price.Currency): Models.Price.Price | undefined => { - if (typeof value === 'undefined') return undefined; - return { - value, - 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/resources/resources.mapper.ts b/packages/integrations/medusajs/src/modules/resources/resources.mapper.ts index bb30223ea..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,7 @@ -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 => { @@ -86,25 +85,6 @@ export const mapService = ( }; }; -/** AddressDTO with first_name/last_name as returned by the custom resources API */ -type AddressDTOWithNames = AddressDTO & { first_name?: string; last_name?: string }; - -const mapAddress = (address?: AddressDTOWithNames): Models.Address.Address | undefined => { - if (!address) return undefined; - return { - firstName: address.first_name, - lastName: address.last_name, - 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']; function mapContractStatus(status: string | undefined): Resources.Model.ContractStatus { diff --git a/packages/integrations/medusajs/src/utils/address.ts b/packages/integrations/medusajs/src/utils/address.ts new file mode 100644 index 000000000..cc8ff3ac7 --- /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: address.address_2 ?? undefined, + apartment: address.address_2 ?? undefined, + city: address.city ?? '', + postalCode: address.postal_code ?? '', + phone: address.phone ?? undefined, + }; +} diff --git a/packages/integrations/medusajs/src/utils/price.ts b/packages/integrations/medusajs/src/utils/price.ts index f78a994db..a742d3163 100644 --- a/packages/integrations/medusajs/src/utils/price.ts +++ b/packages/integrations/medusajs/src/utils/price.ts @@ -2,12 +2,25 @@ import { BadRequestException } from '@nestjs/common'; import { Models } from '@o2s/framework/modules'; -export function mapPriceRequired( +/** + * 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 (!value) { + if (typeof value === 'undefined' || value === null) { throw new BadRequestException(`${context}: price value is missing or invalid`); } return { value, currency }; From b39c9aab03960b19ad8d2050eee9a0667d971a30 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Tue, 17 Feb 2026 13:47:21 +0100 Subject: [PATCH 19/27] refactor(scripts): filter out `locale` query param in Postman collection generation --- scripts/generate-postman-collection.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/generate-postman-collection.mjs b/scripts/generate-postman-collection.mjs index acabf8cce..63988d0b4 100644 --- a/scripts/generate-postman-collection.mjs +++ b/scripts/generate-postman-collection.mjs @@ -71,11 +71,14 @@ function createRequest(name, method, path, query = [], body = null, description } }); + // 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: query.map((q) => ({ key: q.key, value: q.value || `{{${q.key}}}` })), + query: filteredQuery.map((q) => ({ key: q.key, value: q.value || `{{${q.key}}}` })), }; const request = { From 6fadbea168dbbcf9cef4018261c579a9a58fbf09 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Wed, 18 Feb 2026 11:23:20 +0100 Subject: [PATCH 20/27] fix(blocks.product-list): remove permission checks for unauthenticated access to ProductList block --- .changeset/good-webs-wish.md | 5 +++++ .../api-harmonization/product-list.service.ts | 21 +------------------ .../src/frontend/ProductList.server.tsx | 5 ----- 3 files changed, 6 insertions(+), 25 deletions(-) create mode 100644 .changeset/good-webs-wish.md 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/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..81d6b685d 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 @@ -36,26 +36,7 @@ export class ProductListService { 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; - }), - ); + .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 ; }; From 5ac2db899a937c69cce806187e811447e8084830 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Wed, 18 Feb 2026 11:32:57 +0100 Subject: [PATCH 21/27] fix(integrations.medusajs): include `shipping_methods` in cart item fields --- .../integrations/medusajs/src/modules/carts/carts.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 7844a1360..d240edd48 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -28,7 +28,7 @@ export class CartsService extends Carts.Service { private readonly sdk: Medusa; private readonly defaultCurrency: string; - private readonly cartItemsFields = '*items'; + private readonly cartItemsFields = '*items,*shipping_methods'; constructor( private readonly config: ConfigService, From 359a2e9a5434af345095cefd37d2a06d772eeb93 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Wed, 18 Feb 2026 11:39:41 +0100 Subject: [PATCH 22/27] fix(integrations.medusajs): include `fields` parameter for cart methods tests --- .../src/modules/carts/carts.service.spec.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts index 05149eab8..52574eaf8 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -126,7 +126,11 @@ describe('CartsService', () => { const result = await firstValueFrom(service.getCart({ id: 'cart_1' }, 'Bearer token')); - expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith('cart_1', {}, expect.any(Object)); + 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'); @@ -159,7 +163,7 @@ describe('CartsService', () => { expect(mockSdk.store.cart.create).toHaveBeenCalledWith( { currency_code: 'eur', region_id: 'reg_1', metadata: undefined }, - {}, + { fields: '*items,*shipping_methods' }, expect.any(Object), ); }); @@ -194,11 +198,15 @@ describe('CartsService', () => { ), ); - expect(mockSdk.store.cart.retrieve).toHaveBeenCalledWith('cart_1', {}, expect.any(Object)); + 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(); @@ -234,7 +242,7 @@ describe('CartsService', () => { 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(); @@ -270,7 +278,7 @@ describe('CartsService', () => { 'cart_1', 'item_1', { quantity: 3, metadata: undefined }, - {}, + { fields: '*items,*shipping_methods' }, expect.any(Object), ); expect(result?.id).toBe('cart_1'); @@ -287,7 +295,12 @@ describe('CartsService', () => { service.removeCartItem({ cartId: 'cart_1', itemId: 'item_1' }, 'Bearer token'), ); - expect(mockSdk.store.cart.deleteLineItem).toHaveBeenCalledWith('cart_1', 'item_1', expect.any(Object)); + expect(mockSdk.store.cart.deleteLineItem).toHaveBeenCalledWith( + 'cart_1', + 'item_1', + { fields: '*items,*shipping_methods' }, + expect.any(Object), + ); expect(result?.id).toBe('cart_1'); }); @@ -326,7 +339,7 @@ describe('CartsService', () => { 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'); @@ -368,7 +381,7 @@ describe('CartsService', () => { 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(); @@ -409,7 +422,7 @@ describe('CartsService', () => { expect(mockSdk.store.cart.addShippingMethod).toHaveBeenCalledWith( 'cart_1', { option_id: 'opt_1' }, - {}, + { fields: '*items,*shipping_methods' }, expect.any(Object), ); expect(result).toBeDefined(); From 407082018f2f9ab3b40d9ac18477a92b9ca6a7e5 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Wed, 18 Feb 2026 12:37:42 +0100 Subject: [PATCH 23/27] fix(integrations.medusajs): add missing auth token support for Store API product methods --- .changeset/red-peaches-strive.md | 6 + .../product-details.service.ts | 17 ++- .../api-harmonization/product-list.service.ts | 21 +-- .../src/modules/products/products.mapper.ts | 63 ++++++--- .../modules/products/products.service.spec.ts | 70 ++++++---- .../src/modules/products/products.service.ts | 122 +++++++++++++----- 6 files changed, 205 insertions(+), 94 deletions(-) create mode 100644 .changeset/red-peaches-strive.md 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/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 81d6b685d..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,15 +27,18 @@ 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, - }) + .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/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 365c03e40..4ed73ce3b 100644 --- a/packages/integrations/medusajs/src/modules/products/products.service.ts +++ b/packages/integrations/medusajs/src/modules/products/products.service.ts @@ -17,20 +17,30 @@ 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( From 60a4695ac62402385ee5cd949dbc33d49f61241b Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Wed, 18 Feb 2026 13:15:28 +0100 Subject: [PATCH 24/27] fix(integrations.medusajs): handle undefined shipping option prices and remove unused address fields --- .../medusajs/src/modules/checkout/checkout.service.ts | 6 ++++-- packages/integrations/medusajs/src/utils/address.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts index 82a138a5a..1a3810691 100644 --- a/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts +++ b/packages/integrations/medusajs/src/modules/checkout/checkout.service.ts @@ -274,7 +274,7 @@ export class CheckoutService extends Checkout.Service { })), catchError((error) => { this.logger.warn(`Failed to calculate price for shipping option ${option.id}`, error); - return of({ optionId: option.id, calculatedOption: option }); + return of({ optionId: option.id, calculatedOption: undefined }); }), ), ); @@ -284,7 +284,9 @@ export class CheckoutService extends Checkout.Service { map((calculatedResults) => { const calculatedMap = new Map(); calculatedResults.forEach((result) => { - calculatedMap.set(result.optionId, result.calculatedOption); + if (result.calculatedOption) { + calculatedMap.set(result.optionId, result.calculatedOption); + } }); const enrichedOptions = shippingOptions.map((option) => diff --git a/packages/integrations/medusajs/src/utils/address.ts b/packages/integrations/medusajs/src/utils/address.ts index cc8ff3ac7..b6d1c8e65 100644 --- a/packages/integrations/medusajs/src/utils/address.ts +++ b/packages/integrations/medusajs/src/utils/address.ts @@ -31,7 +31,7 @@ export function mapAddress(address?: MedusaAddressFields | null): Models.Address district: address.province ?? '', region: address.province ?? '', streetName: address.address_1 ?? '', - streetNumber: address.address_2 ?? undefined, + streetNumber: undefined, // Medusa does not store street number separately apartment: address.address_2 ?? undefined, city: address.city ?? '', postalCode: address.postal_code ?? '', From 2a599ba2da5bacfa845748e16d9fa42a0b3f96d1 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Wed, 18 Feb 2026 13:32:01 +0100 Subject: [PATCH 25/27] docs(integrations.medusajs): update Medusa.js integration docs - add setup and usage guides - update cart and checkout documentation --- .../commerce/medusa-js/cart-checkout.md | 120 ++++--- .../commerce/medusa-js/features.md | 21 +- .../commerce/medusa-js/how-to-setup.md | 288 ++++++++++++++++ .../commerce/medusa-js/overview.md | 188 +++------- .../integrations/commerce/medusa-js/usage.md | 320 ++++++++++++++++++ 5 files changed, 740 insertions(+), 197 deletions(-) create mode 100644 apps/docs/docs/integrations/commerce/medusa-js/how-to-setup.md create mode 100644 apps/docs/docs/integrations/commerce/medusa-js/usage.md diff --git a/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md b/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md index f0ede59ac..72e9bb776 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/cart-checkout.md @@ -1,79 +1,97 @@ --- -sidebar_position: 300 +sidebar_position: 400 --- # Cart & Checkout -This document describes the cart lifecycle and checkout process when using the Medusa.js integration. +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. -## Cart Lifecycle +## Understanding MedusaJS Concepts -The typical flow is: +If you are familiar with O2S but not Medusa, these concepts are essential: -1. **Create cart** — `POST /carts` with `currency` (and `region_id` for Medusa) -2. **Add items** — `POST /carts/items` with `variantId`, `quantity`, optional `cartId` -3. **Update addresses** — via checkout: `POST /checkout/:cartId/addresses` -4. **Add shipping method** — `POST /checkout/:cartId/shipping-method` -5. **Set payment** — `POST /checkout/:cartId/payment` -6. **Place order** — `POST /checkout/:cartId/place-order` +### Regions -The frontend orchestrates these steps. The API does not track checkout state; each action is independent. +Medusa requires a **`region_id`** for every cart. Regions determine: -## Checkout API Endpoints +- **Pricing** — Currency and tax rules +- **Shipping** — Available shipping options +- **Taxes** — Tax calculation for the region -| Method | Route | 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 (cart + totals + addresses) | -| POST | `/checkout/:cartId/place-order` | Create order from cart | -| POST | `/checkout/:cartId/complete` | One-shot complete flow (addresses → shipping → payment → order) | +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. -## Medusa-Specific Behavior +### Variants vs Products -### Cart Creation +Medusa uses **product variants** for cart line items, not products directly. Each variant has its own ID, SKU, price, and options (size, color, etc.). -- **`region_id`** — Required. Medusa associates carts with regions (pricing, shipping, taxes). -- **`currency_code`** — Required. Use `DEFAULT_CURRENCY` when not provided. +- **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. -### Adding Items +### Currency -- **`variantId` required** — Medusa uses product variants, not productId alone. Use the variant ID from the product catalog. -- When `cartId` is omitted and no active cart exists, a new cart is created via `sdk.store.cart.create` (or `createCartAndAddItem`). +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. -### Shipping Options +### Cart Ownership -- Fetched from `sdk.store.fulfillment.listCartOptions({ cart_id })`. -- For options with `price_type: calculated`, prices are computed via `sdk.store.fulfillment.calculate`. +- **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. -### Order Placement +## O2S to MedusaJS Mapping -- Uses `sdk.store.cart.complete(cartId)` to convert the cart into an order in Medusa. -- Returns the created order; no separate order creation endpoint is used. +### Cart Model -### Guest Checkout +The O2S `Cart` model maps to Medusa's `StoreCart`: -- Supported. No authentication required. -- **Email** — Required for guest order placement (passed in `placeOrder` or `completeCheckout` body). -- Addresses must be provided inline; saved-address IDs are for authenticated users only. +| 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` | -### Limitations +### Checkout Flow Mapping -| Capability | MedusaJS | Notes | -| ---------------- | -------- | ---------------------------------------- | -| `getCartList` | No | Store API does not support listing carts | -| `getCurrentCart` | No | No way to query carts by customer | -| `deleteCart` | No-op | Store API has no cart delete endpoint | +Each O2S checkout step maps to a Medusa operation: -## Dependencies +| 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 | -Checkout delegates to: +### Payment Session Storage -- **Carts** — `updateCartAddresses`, `addShippingMethod` -- **Customers** — Resolve `shippingAddressId` / `billingAddressId` for authenticated users -- **Payments** — `createSession` for payment setup -- **Medusa SDK** — Fulfillment options, `cart.complete` +After `setPayment`, the integration stores: -Ensure Carts, Checkout, Customers, and Payments are configured with Medusa.js (see [Configuration](./overview.md#step-1-update-the-integration-configs)). +- **`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 480a839cb..1a80523b8 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/features.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/features.md @@ -6,6 +6,8 @@ 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 | @@ -13,7 +15,7 @@ This document provides an overview of features supported by the Medusa.js integr | [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)) | @@ -73,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 @@ -82,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} @@ -155,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 9f37772ae..5f3315156 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/overview.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/overview.md @@ -4,163 +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 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, 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'; - -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`:** +- 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 -```typescript -import { Config, Integration } from '@o2s/integrations.medusajs/integration'; +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. -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/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`. - -## Environment variables +## Supported modules -Configure the following environment variables in your API Harmonization server: +The integration implements these modules from the O2S framework: -| 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 | +| 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 | -You can obtain these values from your Medusa Admin Panel: +## Quick start -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" +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 -For more details about authentication setup, see the official [Medusa.js SDK documentation](https://docs.medusajs.com/resources/js-sdk). +For detailed instructions, see the [How to set up](./how-to-setup.md) guide. -## Supported modules +## Requirements -The integration implements these modules from the O2S framework: +- 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 -| 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 | +:::info SSO Authentication Plugin Required -## Dependencies +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. -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 ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ @@ -168,10 +78,14 @@ The integration uses the official Medusa.js SDK for most operations, combined wi └─────────────────┘ └──────────────────┘ └─────────────────┘ │ │ │ Medusa.js SDK │ Admin API - │ Store API │ Store API + │ Store API │ (Related Products, + │ (Products, Orders, │ Resources plugin) + │ Carts, Checkout, │ + │ Customers, Payments) │ + │ Requires SSO auth plugin│ ▼ ▼ ┌──────────────────┐ ┌─────────────────┐ │ Carts/Checkout/ │ │ Assets/Services │ - │ Orders/Products │ │ (plugin) │ + │ 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). From c3be5b1948886c1b752bd0f0a0e0dc03705a11c9 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Wed, 18 Feb 2026 13:44:13 +0100 Subject: [PATCH 26/27] fix(integrations.mocked): add missing auth token support for product service methods --- .../mocked/src/modules/products/products.service.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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()); } } From 3e3753f252cc43921f467c5ebab76d2b26e15145 Mon Sep 17 00:00:00 2001 From: Marcin Krasowski Date: Thu, 19 Feb 2026 10:44:59 +0100 Subject: [PATCH 27/27] docs(integrations): update integration overviews --- apps/docs/docs/integrations/articles/zendesk/overview.md | 2 +- apps/docs/docs/integrations/cache/redis/overview.md | 2 +- apps/docs/docs/integrations/cms/contentful/overview.md | 2 +- apps/docs/docs/integrations/cms/strapi/overview.md | 2 +- apps/docs/docs/integrations/commerce/medusa-js/overview.md | 2 +- apps/docs/docs/integrations/forms/surveyjs/overview.md | 2 +- apps/docs/docs/integrations/search/algolia/overview.md | 2 +- apps/docs/docs/integrations/tickets/zendesk/overview.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) 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/overview.md b/apps/docs/docs/integrations/commerce/medusa-js/overview.md index 5f3315156..09f3c85d2 100644 --- a/apps/docs/docs/integrations/commerce/medusa-js/overview.md +++ b/apps/docs/docs/integrations/commerce/medusa-js/overview.md @@ -4,7 +4,7 @@ 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, cart and checkout, 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 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/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