Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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 this issue Apr 11, 2024 · 3 comments

Comments

@ebdrup
Copy link

ebdrup commented Apr 11, 2024

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
Copy link
Author

ebdrup commented Apr 11, 2024

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
Copy link
Author

ebdrup commented Apr 11, 2024

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
Copy link
Contributor

olivermrbl commented Apr 16, 2024

@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:

pricingService.setProductPrices(computedProducts, {
cart_id: cart_id,
region_id: regionId,
currency_code: currencyCode,
customer_id: req.user?.customer_id,
include_discount_prices: true,
})

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants