medusajs / medusa

Building blocks for digital commerce
https://medusajs.com
MIT License
24.45k stars 2.4k forks source link

Customer_id not set in the context of a pricing strategy when doing medusa.products.list, called with a cart that has a customer assigned. #7055

Open ebdrup opened 4 months ago

ebdrup commented 4 months ago

Bug report (I think)

Describe the bug

I have created a customer in medusa, and I have created a cart in medusa and updated the cart with the customer id of the customer I created.

Now I list products with medusa.products.list and pass the cart_id as the cart I have created.

I have then implemented a Pricing Strategy, that gets run when I call medusa.products.list, but for some reason the customer_id is not set in the context passed to the pricing strategy.

The context in the pricing strategy has the right cart_id. When I look up the cart in the medusa DB for that id, the cart does have a customer_id set, but the pricing strategy does not have the customer_id of the cart in it's context.

When adding items to the cart, the pricing strategy gets called with a context that has the right cart_id and customer_id.

How do I make the customer_id be set in the context of the pricing strategy, when doing medusa.products.list?

System information

Medusa version (including plugins): 1.20.1 Node.js version: v20.9.0 Database: Postgesql Operating system: OSX Browser (if relevant):

Steps to reproduce the behavior

I have created a customer in medusa, and I have created a cart in medusa and updated the cart with the customer id of the customer I created.

Now I list products with medusa.products.list and pass the cart_id as the cart I have created.

I have then implemented a Pricing Strategy, that gets run when I call medusa.products.list, but for some reason the customer_id is not set in the context passed to the pricing strategy.

Expected behavior

The customer_id is set in the context of the Pricing strategy, when calling medusa.products.list with a cart_id of a cart that has a customer assigned.

ebdrup commented 4 months ago

Here is my pricing strategy

import {
  AbstractPriceSelectionStrategy,
  Customer,
  CustomerGroup,
  MoneyAmount,
  PriceSelectionContext,
  PriceSelectionResult,
  PriceType,
} from '@medusajs/medusa';
import MoneyAmountRepository from '@medusajs/medusa/dist/repositories/money-amount';
import { TaxServiceRate } from '@medusajs/medusa/dist/types/tax-service';
import { FlagRouter, TaxInclusivePricingFeatureFlag } from '@medusajs/utils';
import { isDefined } from 'medusa-core-utils';
import { EntityManager } from 'typeorm';

class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
  protected manager_: EntityManager;
  protected readonly featureFlagRouter_: FlagRouter;
  protected moneyAmountRepository_: typeof MoneyAmountRepository;

  constructor({
    manager,
    featureFlagRouter,
    moneyAmountRepository,
  }: {
    manager: EntityManager;
    featureFlagRouter: FlagRouter;
    moneyAmountRepository: typeof MoneyAmountRepository;
  }) {
    // @ts-expect-error Not sure
    // eslint-disable-next-line prefer-rest-params
    super(...arguments);
    this.manager_ = manager;
    this.moneyAmountRepository_ = moneyAmountRepository;
    this.featureFlagRouter_ = featureFlagRouter;
    console.info('CONSTRUCTOR PriceSelectionStrategy');
  }

  async calculateVariantPrice(
    data: {
      variantId: string;
      quantity?: number;
    }[],
    context: PriceSelectionContext,
  ): Promise<Map<string, PriceSelectionResult>> {
    const dataMap = new Map(data.map((d) => [d.variantId, d]));

    console.info(context);

    const nonCachedData: {
      variantId: string;
      quantity?: number;
    }[] = [];

    const variantPricesMap = new Map<string, PriceSelectionResult>();

    nonCachedData.push(...dataMap.values());

    let results: Map<string, PriceSelectionResult> = new Map();

    if (
      this.featureFlagRouter_.isFeatureEnabled(
        TaxInclusivePricingFeatureFlag.key,
      )
    ) {
      results = await this.calculateVariantPrice_new(nonCachedData, context);
    } else {
      results = await this.calculateVariantPrice_old(nonCachedData, context);
    }

    [...results].map(([variantId, prices]) => {
      variantPricesMap.set(variantId, prices);
    });

    return variantPricesMap;
  }

  private async modifyVariantPrices(
    variantPrices: Record<string, MoneyAmount[]>,
    customerId?: Customer['id'],
  ): Promise<Record<string, MoneyAmount[]>> {
    if (!customerId) return variantPrices;
    const customerGroup = await this.manager_
      .createQueryBuilder(CustomerGroup, 'customer_group')
      .leftJoinAndSelect('customer_group.customers', 'customers')
      .where('customers_customer_group.customer_id = :customerId', {
        customerId,
      })
      .getOne();
    const companyId = customerGroup?.metadata.martsCompanyId;
    //Based on the region and companyId we set the margins
    console.info({ companyId });
    const margin = 50000;
    console.info('Adding margin', margin);
    for (const prices of Object.values(variantPrices)) {
      for (const price of prices) {
        price.amount = Math.round(price.amount * (1 + margin / 100));
      }
    }
    return variantPrices;
  }

  private async calculateVariantPrice_new(
    data: {
      variantId: string;
      quantity?: number;
    }[],
    context: PriceSelectionContext,
  ): Promise<Map<string, PriceSelectionResult>> {
    const moneyRepo = this.activeManager_.withRepository(
      this.moneyAmountRepository_,
    );
    const [variantsPricesUnmodified] =
      await moneyRepo.findManyForVariantsInRegion(
        data.map((d) => d.variantId),
        context.region_id,
        context.currency_code,
        context.customer_id,
        context.include_discount_prices,
      );

    const variantsPrices = await this.modifyVariantPrices(
      variantsPricesUnmodified,
      context.customer_id,
    );

    const variantPricesMap = new Map<string, PriceSelectionResult>();

    for (const [variantId, prices] of Object.entries(variantsPrices)) {
      const dataItem = data.find((d) => d.variantId === variantId)!;

      const result: PriceSelectionResult = {
        originalPrice: null,
        calculatedPrice: null,
        prices,
        originalPriceIncludesTax: null,
        calculatedPriceIncludesTax: null,
      };

      if (!prices.length || !context) {
        variantPricesMap.set(variantId, result);
      }

      const taxRate = context.tax_rates?.reduce(
        (accRate: number, nextTaxRate: TaxServiceRate) => {
          return accRate + (nextTaxRate.rate || 0) / 100;
        },
        0,
      );

      for (const ma of prices) {
        let isTaxInclusive = ma.currency?.includes_tax || false;

        if (ma.price_list?.includes_tax) {
          // PriceList specific price so use the PriceList tax setting
          isTaxInclusive = ma.price_list.includes_tax;
        } else if (ma.region?.includes_tax) {
          // Region specific price so use the Region tax setting
          isTaxInclusive = ma.region.includes_tax;
        }

        delete ma.currency;
        delete ma.region;

        if (
          context.region_id &&
          ma.region_id === context.region_id &&
          ma.price_list_id === null &&
          ma.min_quantity === null &&
          ma.max_quantity === null
        ) {
          result.originalPriceIncludesTax = isTaxInclusive;
          result.originalPrice = ma.amount;
        }

        if (
          context.currency_code &&
          ma.currency_code === context.currency_code &&
          ma.price_list_id === null &&
          ma.min_quantity === null &&
          ma.max_quantity === null &&
          result.originalPrice === null // region prices take precedence
        ) {
          result.originalPriceIncludesTax = isTaxInclusive;
          result.originalPrice = ma.amount;
        }

        if (
          isValidQuantity(ma, dataItem.quantity) &&
          isValidAmount(ma.amount, result, isTaxInclusive, taxRate) &&
          ((context.currency_code &&
            ma.currency_code === context.currency_code) ||
            (context.region_id && ma.region_id === context.region_id))
        ) {
          result.calculatedPrice = ma.amount;
          result.calculatedPriceType = ma.price_list?.type || PriceType.DEFAULT;
          result.calculatedPriceIncludesTax = isTaxInclusive;
        }
      }

      variantPricesMap.set(variantId, result);
    }

    return variantPricesMap;
  }

  private async calculateVariantPrice_old(
    data: {
      variantId: string;
      quantity?: number;
    }[],
    context: PriceSelectionContext,
  ): Promise<Map<string, PriceSelectionResult>> {
    const moneyRepo = this.activeManager_.withRepository(
      this.moneyAmountRepository_,
    );

    const [variantsPricesUnmodified] =
      await moneyRepo.findManyForVariantsInRegion(
        data.map((d) => d.variantId),
        context.region_id,
        context.currency_code,
        context.customer_id,
        context.include_discount_prices,
      );

    const variantsPrices = await this.modifyVariantPrices(
      variantsPricesUnmodified,
      context.customer_id,
    );

    const variantPricesMap = new Map<string, PriceSelectionResult>();

    for (const [variantId, prices] of Object.entries(variantsPrices)) {
      const dataItem = data.find((d) => d.variantId === variantId)!;

      const result: PriceSelectionResult = {
        originalPrice: null,
        calculatedPrice: null,
        prices,
      };

      if (!prices.length || !context) {
        variantPricesMap.set(variantId, result);
      }

      for (const ma of prices) {
        delete ma.currency;
        delete ma.region;

        if (
          context.region_id &&
          ma.region_id === context.region_id &&
          ma.price_list_id === null &&
          ma.min_quantity === null &&
          ma.max_quantity === null
        ) {
          result.originalPrice = ma.amount;
        }

        if (
          context.currency_code &&
          ma.currency_code === context.currency_code &&
          ma.price_list_id === null &&
          ma.min_quantity === null &&
          ma.max_quantity === null &&
          result.originalPrice === null // region prices take precedence
        ) {
          result.originalPrice = ma.amount;
        }

        if (
          isValidQuantity(ma, dataItem.quantity) &&
          (result.calculatedPrice === null ||
            ma.amount < result.calculatedPrice) &&
          ((context.currency_code &&
            ma.currency_code === context.currency_code) ||
            (context.region_id && ma.region_id === context.region_id))
        ) {
          result.calculatedPrice = ma.amount;
          result.calculatedPriceType = ma.price_list?.type || PriceType.DEFAULT;
        }
      }

      variantPricesMap.set(variantId, result);
    }

    return variantPricesMap;
  }
}

const isValidAmount = (
  amount: number,
  result: PriceSelectionResult,
  isTaxInclusive: boolean,
  taxRate?: number,
): boolean => {
  if (result.calculatedPrice === null) {
    return true;
  }

  if (isTaxInclusive === result.calculatedPriceIncludesTax) {
    // if both or neither are tax inclusive compare equally
    return amount < result.calculatedPrice;
  }

  if (typeof taxRate !== 'undefined') {
    return isTaxInclusive
      ? amount < (1 + taxRate) * result.calculatedPrice
      : (1 + taxRate) * amount < result.calculatedPrice;
  }

  // if we dont have a taxrate we can't compare mixed prices
  return false;
};

const isValidQuantity = (price: MoneyAmount, quantity?: number): boolean =>
  (isDefined(quantity) && isValidPriceWithQuantity(price, quantity)) ||
  (typeof quantity === 'undefined' && isValidPriceWithoutQuantity(price));

const isValidPriceWithoutQuantity = (price: MoneyAmount): boolean =>
  !!(
    (!price.max_quantity && !price.min_quantity) ||
    ((!price.min_quantity || price.min_quantity === 0) && price.max_quantity)
  );

const isValidPriceWithQuantity = (
  price: MoneyAmount,
  quantity: number,
): boolean =>
  (!price.min_quantity || price.min_quantity <= quantity) &&
  (!price.max_quantity || price.max_quantity >= quantity);

export default PriceSelectionStrategy;
ebdrup commented 4 months ago

The console log of the pricing strategy is:

CONSTRUCTOR PriceSelectionStrategy
{
  cart_id: 'cart_01HV68CY8ZN96DSTEXR0S65KCS',
  region_id: 'reg_01HMBFAX5CV30TN32NEPNF7V2Q',
  currency_code: 'dkk',
  customer_id: undefined,
  include_discount_prices: true,
  tax_rates: [ { rate: 25, name: 'default', code: 'default' } ]
}

And for the cart with ID cart_01HV68CY8ZN96DSTEXR0S65KCS the DB shows it has a customer_id:

image

I would expect the customer_id of the context of the pricing strategy being logged, to be set to the customer_id of the cart

olivermrbl commented 4 months ago

@ebdrup right now, only signed-in customers will get customer-specific pricing. The customer_id of the context is set using the authenticated session object:

https://github.com/medusajs/medusa/blob/122b3ea76b790eff1e7ec64810662505370c26e9/packages/medusa/src/api/routes/store/products/list-products.ts#L325-L331

This does not immediately solve your need (unless your customers will be signed in), but thought I'd highlight, so you know why customer_id shows up as undefined.