Explorar el Código

feat(core): Implement MoneyStrategy

Relates to #1835.

BREAKING CHANGE: The introduction of the new MoneyStrategy includes a new GraphQL `Money` scalar,
which replaces `Int` used in v1.x. In practice, this is still a `number` type and should not
break any client applications. One point to note is that `Money` is based on the `Float` scalar
and therefore can represent decimal values, allowing fractions of cents to be represented.
Michael Bromley hace 2 años
padre
commit
61ac0418c4
Se han modificado 63 ficheros con 5553 adiciones y 5071 borrados
  1. 309 229
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  2. 2 0
      packages/admin-ui/src/lib/core/src/data/data.module.ts
  3. 511 529
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 630 646
      packages/common/src/generated-shop-types.ts
  5. 3 0
      packages/core/e2e/fixtures/e2e-products-money-handling.csv
  6. 511 529
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  7. 601 617
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  8. 2 0
      packages/core/e2e/graphql/shop-definitions.ts
  9. 135 0
      packages/core/e2e/money-strategy.e2e-spec.ts
  10. 0 1
      packages/core/e2e/order-modification.e2e-spec.ts
  11. 2 2
      packages/core/e2e/order-promotion.e2e-spec.ts
  12. 1 15
      packages/core/e2e/product-channel.e2e-spec.ts
  13. 1 1
      packages/core/src/api/common/id-codec.ts
  14. 2 1
      packages/core/src/api/config/generate-list-options.ts
  15. 5 2
      packages/core/src/api/config/generate-resolvers.ts
  16. 68 0
      packages/core/src/api/config/money-scalar.ts
  17. 1 1
      packages/core/src/api/schema/admin-api/order-admin.type.graphql
  18. 3 3
      packages/core/src/api/schema/admin-api/order.api.graphql
  19. 2 2
      packages/core/src/api/schema/admin-api/product.api.graphql
  20. 2 2
      packages/core/src/api/schema/admin-api/shipping-method.api.graphql
  21. 6 3
      packages/core/src/api/schema/common/common-types.graphql
  22. 43 43
      packages/core/src/api/schema/common/order.type.graphql
  23. 3 3
      packages/core/src/api/schema/common/product-search.type.graphql
  24. 2 2
      packages/core/src/api/schema/common/product.type.graphql
  25. 3 0
      packages/core/src/bootstrap.ts
  26. 11 0
      packages/core/src/common/round-money.ts
  27. 2 2
      packages/core/src/common/tax-utils.ts
  28. 4 3
      packages/core/src/config/catalog/default-product-variant-price-calculation-strategy.spec.ts
  29. 2 1
      packages/core/src/config/catalog/default-product-variant-price-calculation-strategy.ts
  30. 1 1
      packages/core/src/config/config.service.mock.ts
  31. 1 1
      packages/core/src/config/config.service.ts
  32. 3 1
      packages/core/src/config/default-config.ts
  33. 0 0
      packages/core/src/config/entity/auto-increment-id-strategy.ts
  34. 0 0
      packages/core/src/config/entity/base64-id-strategy.ts
  35. 44 0
      packages/core/src/config/entity/bigint-money-strategy.ts
  36. 23 0
      packages/core/src/config/entity/default-money-strategy.ts
  37. 0 0
      packages/core/src/config/entity/entity-id-strategy.ts
  38. 64 0
      packages/core/src/config/entity/money-strategy.ts
  39. 0 0
      packages/core/src/config/entity/uuid-id-strategy.ts
  40. 6 3
      packages/core/src/config/index.ts
  41. 10 1
      packages/core/src/config/vendure-config.ts
  42. 51 0
      packages/core/src/entity/money.decorator.ts
  43. 108 59
      packages/core/src/entity/order-line/order-line.entity.ts
  44. 2 1
      packages/core/src/entity/order-modification/order-modification.entity.ts
  45. 5 4
      packages/core/src/entity/order/order.entity.ts
  46. 2 1
      packages/core/src/entity/payment/payment.entity.ts
  47. 2 1
      packages/core/src/entity/product-variant/product-variant-price.entity.ts
  48. 7 8
      packages/core/src/entity/product-variant/product-variant.entity.ts
  49. 4 3
      packages/core/src/entity/promotion/promotion.entity.ts
  50. 5 4
      packages/core/src/entity/refund/refund.entity.ts
  51. 1 1
      packages/core/src/entity/set-entity-id-strategy.ts
  52. 19 0
      packages/core/src/entity/set-money-strategy.ts
  53. 18 10
      packages/core/src/entity/shipping-line/shipping-line.entity.ts
  54. 4 2
      packages/core/src/entity/shipping-method/shipping-method.entity.ts
  55. 2 1
      packages/core/src/entity/surcharge/surcharge.entity.ts
  56. 3 2
      packages/core/src/plugin/default-search-plugin/entities/search-index-item.entity.ts
  57. 42 4
      packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts
  58. 2 1
      packages/core/src/service/services/product-variant.service.ts
  59. 4 4
      packages/dev-server/dev-config.ts
  60. 511 529
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  61. 511 529
      packages/payments-plugin/e2e/graphql/generated-admin-types.ts
  62. 601 617
      packages/payments-plugin/e2e/graphql/generated-shop-types.ts
  63. 630 646
      packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts

+ 309 - 229
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -1,232 +1,312 @@
 // tslint:disable
 
-export interface PossibleTypesResultData {
-    possibleTypes: {
-        [key: string]: string[];
-    };
-}
-const result: PossibleTypesResultData = {
-    possibleTypes: {
-        AddFulfillmentToOrderResult: [
-            'CreateFulfillmentError',
-            'EmptyOrderLineSelectionError',
-            'Fulfillment',
-            'FulfillmentStateTransitionError',
-            'InsufficientStockOnHandError',
-            'InvalidFulfillmentHandlerError',
-            'ItemsAlreadyFulfilledError',
-        ],
-        AddManualPaymentToOrderResult: ['ManualPaymentStateError', 'Order'],
-        ApplyCouponCodeResult: [
-            'CouponCodeExpiredError',
-            'CouponCodeInvalidError',
-            'CouponCodeLimitError',
-            'Order',
-        ],
-        AuthenticationResult: ['CurrentUser', 'InvalidCredentialsError'],
-        CancelOrderResult: [
-            'CancelActiveOrderError',
-            'EmptyOrderLineSelectionError',
-            'MultipleOrderError',
-            'Order',
-            'OrderStateTransitionError',
-            'QuantityTooGreatError',
-        ],
-        CancelPaymentResult: ['CancelPaymentError', 'Payment', 'PaymentStateTransitionError'],
-        CreateAssetResult: ['Asset', 'MimeTypeError'],
-        CreateChannelResult: ['Channel', 'LanguageNotAvailableError'],
-        CreateCustomerResult: ['Customer', 'EmailAddressConflictError'],
-        CreatePromotionResult: ['MissingConditionsError', 'Promotion'],
-        CustomField: [
-            'BooleanCustomFieldConfig',
-            'DateTimeCustomFieldConfig',
-            'FloatCustomFieldConfig',
-            'IntCustomFieldConfig',
-            'LocaleStringCustomFieldConfig',
-            'RelationCustomFieldConfig',
-            'StringCustomFieldConfig',
-            'TextCustomFieldConfig',
-        ],
-        CustomFieldConfig: [
-            'BooleanCustomFieldConfig',
-            'DateTimeCustomFieldConfig',
-            'FloatCustomFieldConfig',
-            'IntCustomFieldConfig',
-            'LocaleStringCustomFieldConfig',
-            'RelationCustomFieldConfig',
-            'StringCustomFieldConfig',
-            'TextCustomFieldConfig',
-        ],
-        ErrorResult: [
-            'AlreadyRefundedError',
-            'CancelActiveOrderError',
-            'CancelPaymentError',
-            'ChannelDefaultLanguageError',
-            'CouponCodeExpiredError',
-            'CouponCodeInvalidError',
-            'CouponCodeLimitError',
-            'CreateFulfillmentError',
-            'EmailAddressConflictError',
-            'EmptyOrderLineSelectionError',
-            'FacetInUseError',
-            'FulfillmentStateTransitionError',
-            'IneligibleShippingMethodError',
-            'InsufficientStockError',
-            'InsufficientStockOnHandError',
-            'InvalidCredentialsError',
-            'InvalidFulfillmentHandlerError',
-            'ItemsAlreadyFulfilledError',
-            'LanguageNotAvailableError',
-            'ManualPaymentStateError',
-            'MimeTypeError',
-            'MissingConditionsError',
-            'MultipleOrderError',
-            'NativeAuthStrategyError',
-            'NegativeQuantityError',
-            'NoActiveOrderError',
-            'NoChangesSpecifiedError',
-            'NothingToRefundError',
-            'OrderLimitError',
-            'OrderModificationError',
-            'OrderModificationStateError',
-            'OrderStateTransitionError',
-            'PaymentMethodMissingError',
-            'PaymentOrderMismatchError',
-            'PaymentStateTransitionError',
-            'ProductOptionInUseError',
-            'QuantityTooGreatError',
-            'RefundOrderStateError',
-            'RefundPaymentIdMissingError',
-            'RefundStateTransitionError',
-            'SettlePaymentError',
-        ],
-        ModifyOrderResult: [
-            'CouponCodeExpiredError',
-            'CouponCodeInvalidError',
-            'CouponCodeLimitError',
-            'InsufficientStockError',
-            'NegativeQuantityError',
-            'NoChangesSpecifiedError',
-            'Order',
-            'OrderLimitError',
-            'OrderModificationStateError',
-            'PaymentMethodMissingError',
-            'RefundPaymentIdMissingError',
-        ],
-        NativeAuthenticationResult: ['CurrentUser', 'InvalidCredentialsError', 'NativeAuthStrategyError'],
-        Node: [
-            'Address',
-            'Administrator',
-            'Allocation',
-            'Asset',
-            'AuthenticationMethod',
-            'Cancellation',
-            'Channel',
-            'Collection',
-            'Country',
-            'Customer',
-            'CustomerGroup',
-            'Facet',
-            'FacetValue',
-            'Fulfillment',
-            'HistoryEntry',
-            'Job',
-            'Order',
-            'OrderItem',
-            'OrderLine',
-            'OrderModification',
-            'Payment',
-            'PaymentMethod',
-            'Product',
-            'ProductOption',
-            'ProductOptionGroup',
-            'ProductVariant',
-            'Promotion',
-            'Refund',
-            'Release',
-            'Return',
-            'Role',
-            'Sale',
-            'Seller',
-            'ShippingMethod',
-            'StockAdjustment',
-            'StockLevel',
-            'StockLocation',
-            'Surcharge',
-            'Tag',
-            'TaxCategory',
-            'TaxRate',
-            'User',
-            'Zone',
-        ],
-        PaginatedList: [
-            'AdministratorList',
-            'AssetList',
-            'CollectionList',
-            'CountryList',
-            'CustomerGroupList',
-            'CustomerList',
-            'FacetList',
-            'FacetValueList',
-            'HistoryEntryList',
-            'JobList',
-            'OrderList',
-            'PaymentMethodList',
-            'ProductList',
-            'ProductVariantList',
-            'PromotionList',
-            'RoleList',
-            'SellerList',
-            'ShippingMethodList',
-            'StockLocationList',
-            'TagList',
-            'TaxRateList',
-        ],
-        RefundOrderResult: [
-            'AlreadyRefundedError',
-            'MultipleOrderError',
-            'NothingToRefundError',
-            'OrderStateTransitionError',
-            'PaymentOrderMismatchError',
-            'QuantityTooGreatError',
-            'Refund',
-            'RefundOrderStateError',
-            'RefundStateTransitionError',
-        ],
-        RemoveFacetFromChannelResult: ['Facet', 'FacetInUseError'],
-        RemoveOptionGroupFromProductResult: ['Product', 'ProductOptionInUseError'],
-        RemoveOrderItemsResult: ['Order', 'OrderModificationError'],
-        SearchResultPrice: ['PriceRange', 'SinglePrice'],
-        SetCustomerForDraftOrderResult: ['EmailAddressConflictError', 'Order'],
-        SetOrderShippingMethodResult: [
-            'IneligibleShippingMethodError',
-            'NoActiveOrderError',
-            'Order',
-            'OrderModificationError',
-        ],
-        SettlePaymentResult: [
-            'OrderStateTransitionError',
-            'Payment',
-            'PaymentStateTransitionError',
-            'SettlePaymentError',
-        ],
-        SettleRefundResult: ['Refund', 'RefundStateTransitionError'],
-        StockMovement: ['Allocation', 'Cancellation', 'Release', 'Return', 'Sale', 'StockAdjustment'],
-        StockMovementItem: ['Allocation', 'Cancellation', 'Release', 'Return', 'Sale', 'StockAdjustment'],
-        TransitionFulfillmentToStateResult: ['Fulfillment', 'FulfillmentStateTransitionError'],
-        TransitionOrderToStateResult: ['Order', 'OrderStateTransitionError'],
-        TransitionPaymentToStateResult: ['Payment', 'PaymentStateTransitionError'],
-        UpdateChannelResult: ['Channel', 'LanguageNotAvailableError'],
-        UpdateCustomerResult: ['Customer', 'EmailAddressConflictError'],
-        UpdateGlobalSettingsResult: ['ChannelDefaultLanguageError', 'GlobalSettings'],
-        UpdateOrderItemsResult: [
-            'InsufficientStockError',
-            'NegativeQuantityError',
-            'Order',
-            'OrderLimitError',
-            'OrderModificationError',
-        ],
-        UpdatePromotionResult: ['MissingConditionsError', 'Promotion'],
-    },
+      export interface PossibleTypesResultData {
+        possibleTypes: {
+          [key: string]: string[]
+        }
+      }
+      const result: PossibleTypesResultData = {
+  "possibleTypes": {
+    "AddFulfillmentToOrderResult": [
+      "CreateFulfillmentError",
+      "EmptyOrderLineSelectionError",
+      "Fulfillment",
+      "FulfillmentStateTransitionError",
+      "InsufficientStockOnHandError",
+      "InvalidFulfillmentHandlerError",
+      "ItemsAlreadyFulfilledError"
+    ],
+    "AddManualPaymentToOrderResult": [
+      "ManualPaymentStateError",
+      "Order"
+    ],
+    "ApplyCouponCodeResult": [
+      "CouponCodeExpiredError",
+      "CouponCodeInvalidError",
+      "CouponCodeLimitError",
+      "Order"
+    ],
+    "AuthenticationResult": [
+      "CurrentUser",
+      "InvalidCredentialsError"
+    ],
+    "CancelOrderResult": [
+      "CancelActiveOrderError",
+      "EmptyOrderLineSelectionError",
+      "MultipleOrderError",
+      "Order",
+      "OrderStateTransitionError",
+      "QuantityTooGreatError"
+    ],
+    "CancelPaymentResult": [
+      "CancelPaymentError",
+      "Payment",
+      "PaymentStateTransitionError"
+    ],
+    "CreateAssetResult": [
+      "Asset",
+      "MimeTypeError"
+    ],
+    "CreateChannelResult": [
+      "Channel",
+      "LanguageNotAvailableError"
+    ],
+    "CreateCustomerResult": [
+      "Customer",
+      "EmailAddressConflictError"
+    ],
+    "CreatePromotionResult": [
+      "MissingConditionsError",
+      "Promotion"
+    ],
+    "CustomField": [
+      "BooleanCustomFieldConfig",
+      "DateTimeCustomFieldConfig",
+      "FloatCustomFieldConfig",
+      "IntCustomFieldConfig",
+      "LocaleStringCustomFieldConfig",
+      "RelationCustomFieldConfig",
+      "StringCustomFieldConfig",
+      "TextCustomFieldConfig"
+    ],
+    "CustomFieldConfig": [
+      "BooleanCustomFieldConfig",
+      "DateTimeCustomFieldConfig",
+      "FloatCustomFieldConfig",
+      "IntCustomFieldConfig",
+      "LocaleStringCustomFieldConfig",
+      "RelationCustomFieldConfig",
+      "StringCustomFieldConfig",
+      "TextCustomFieldConfig"
+    ],
+    "ErrorResult": [
+      "AlreadyRefundedError",
+      "CancelActiveOrderError",
+      "CancelPaymentError",
+      "ChannelDefaultLanguageError",
+      "CouponCodeExpiredError",
+      "CouponCodeInvalidError",
+      "CouponCodeLimitError",
+      "CreateFulfillmentError",
+      "EmailAddressConflictError",
+      "EmptyOrderLineSelectionError",
+      "FacetInUseError",
+      "FulfillmentStateTransitionError",
+      "IneligibleShippingMethodError",
+      "InsufficientStockError",
+      "InsufficientStockOnHandError",
+      "InvalidCredentialsError",
+      "InvalidFulfillmentHandlerError",
+      "ItemsAlreadyFulfilledError",
+      "LanguageNotAvailableError",
+      "ManualPaymentStateError",
+      "MimeTypeError",
+      "MissingConditionsError",
+      "MultipleOrderError",
+      "NativeAuthStrategyError",
+      "NegativeQuantityError",
+      "NoActiveOrderError",
+      "NoChangesSpecifiedError",
+      "NothingToRefundError",
+      "OrderLimitError",
+      "OrderModificationError",
+      "OrderModificationStateError",
+      "OrderStateTransitionError",
+      "PaymentMethodMissingError",
+      "PaymentOrderMismatchError",
+      "PaymentStateTransitionError",
+      "ProductOptionInUseError",
+      "QuantityTooGreatError",
+      "RefundOrderStateError",
+      "RefundPaymentIdMissingError",
+      "RefundStateTransitionError",
+      "SettlePaymentError"
+    ],
+    "ModifyOrderResult": [
+      "CouponCodeExpiredError",
+      "CouponCodeInvalidError",
+      "CouponCodeLimitError",
+      "InsufficientStockError",
+      "NegativeQuantityError",
+      "NoChangesSpecifiedError",
+      "Order",
+      "OrderLimitError",
+      "OrderModificationStateError",
+      "PaymentMethodMissingError",
+      "RefundPaymentIdMissingError"
+    ],
+    "NativeAuthenticationResult": [
+      "CurrentUser",
+      "InvalidCredentialsError",
+      "NativeAuthStrategyError"
+    ],
+    "Node": [
+      "Address",
+      "Administrator",
+      "Allocation",
+      "Asset",
+      "AuthenticationMethod",
+      "Cancellation",
+      "Channel",
+      "Collection",
+      "Country",
+      "Customer",
+      "CustomerGroup",
+      "Facet",
+      "FacetValue",
+      "Fulfillment",
+      "HistoryEntry",
+      "Job",
+      "Order",
+      "OrderItem",
+      "OrderLine",
+      "OrderModification",
+      "Payment",
+      "PaymentMethod",
+      "Product",
+      "ProductOption",
+      "ProductOptionGroup",
+      "ProductVariant",
+      "Promotion",
+      "Refund",
+      "Release",
+      "Return",
+      "Role",
+      "Sale",
+      "Seller",
+      "ShippingMethod",
+      "StockAdjustment",
+      "StockLevel",
+      "StockLocation",
+      "Surcharge",
+      "Tag",
+      "TaxCategory",
+      "TaxRate",
+      "User",
+      "Zone"
+    ],
+    "PaginatedList": [
+      "AdministratorList",
+      "AssetList",
+      "CollectionList",
+      "CountryList",
+      "CustomerGroupList",
+      "CustomerList",
+      "FacetList",
+      "FacetValueList",
+      "HistoryEntryList",
+      "JobList",
+      "OrderList",
+      "PaymentMethodList",
+      "ProductList",
+      "ProductVariantList",
+      "PromotionList",
+      "RoleList",
+      "SellerList",
+      "ShippingMethodList",
+      "StockLocationList",
+      "TagList",
+      "TaxRateList"
+    ],
+    "RefundOrderResult": [
+      "AlreadyRefundedError",
+      "MultipleOrderError",
+      "NothingToRefundError",
+      "OrderStateTransitionError",
+      "PaymentOrderMismatchError",
+      "QuantityTooGreatError",
+      "Refund",
+      "RefundOrderStateError",
+      "RefundStateTransitionError"
+    ],
+    "RemoveFacetFromChannelResult": [
+      "Facet",
+      "FacetInUseError"
+    ],
+    "RemoveOptionGroupFromProductResult": [
+      "Product",
+      "ProductOptionInUseError"
+    ],
+    "RemoveOrderItemsResult": [
+      "Order",
+      "OrderModificationError"
+    ],
+    "SearchResultPrice": [
+      "PriceRange",
+      "SinglePrice"
+    ],
+    "SetCustomerForDraftOrderResult": [
+      "EmailAddressConflictError",
+      "Order"
+    ],
+    "SetOrderShippingMethodResult": [
+      "IneligibleShippingMethodError",
+      "NoActiveOrderError",
+      "Order",
+      "OrderModificationError"
+    ],
+    "SettlePaymentResult": [
+      "OrderStateTransitionError",
+      "Payment",
+      "PaymentStateTransitionError",
+      "SettlePaymentError"
+    ],
+    "SettleRefundResult": [
+      "Refund",
+      "RefundStateTransitionError"
+    ],
+    "StockMovement": [
+      "Allocation",
+      "Cancellation",
+      "Release",
+      "Return",
+      "Sale",
+      "StockAdjustment"
+    ],
+    "StockMovementItem": [
+      "Allocation",
+      "Cancellation",
+      "Release",
+      "Return",
+      "Sale",
+      "StockAdjustment"
+    ],
+    "TransitionFulfillmentToStateResult": [
+      "Fulfillment",
+      "FulfillmentStateTransitionError"
+    ],
+    "TransitionOrderToStateResult": [
+      "Order",
+      "OrderStateTransitionError"
+    ],
+    "TransitionPaymentToStateResult": [
+      "Payment",
+      "PaymentStateTransitionError"
+    ],
+    "UpdateChannelResult": [
+      "Channel",
+      "LanguageNotAvailableError"
+    ],
+    "UpdateCustomerResult": [
+      "Customer",
+      "EmailAddressConflictError"
+    ],
+    "UpdateGlobalSettingsResult": [
+      "ChannelDefaultLanguageError",
+      "GlobalSettings"
+    ],
+    "UpdateOrderItemsResult": [
+      "InsufficientStockError",
+      "NegativeQuantityError",
+      "Order",
+      "OrderLimitError",
+      "OrderModificationError"
+    ],
+    "UpdatePromotionResult": [
+      "MissingConditionsError",
+      "Promotion"
+    ]
+  }
 };
-export default result;
+      export default result;
+    

+ 2 - 0
packages/admin-ui/src/lib/core/src/data/data.module.ts

@@ -86,6 +86,8 @@ export function createApollo(
     };
 }
 
+// List of all EU countries
+
 /**
  * The DataModule is responsible for all API calls *and* serves as the source of truth for global app
  * state via the apollo-link-state package.

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 511 - 529
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 630 - 646
packages/common/src/generated-shop-types.ts


+ 3 - 0
packages/core/e2e/fixtures/e2e-products-money-handling.csv

@@ -0,0 +1,3 @@
+name          , slug          , description                                                                                                                                                                                                                                                                                        , assets                             , facets                                  , optionGroups      , optionValues   , sku      , price   , taxCategory , stockOnHand , trackInventory , variantAssets , variantFacets
+Cheap Widget        , cheap-widget        , "A very affordable widget."                       ,   , category:electronics , "" , ""  , CW0001 , 0.31 , standard    ,             , false          ,               ,
+Expensive Widget , expensive-widget , "A luxury widget with a luxury price tag!" ,  , category:electronics ,       ,         , EW0001  , 9999999.00  , standard    ,             , false          ,               ,

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 511 - 529
packages/core/e2e/graphql/generated-e2e-admin-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 601 - 617
packages/core/e2e/graphql/generated-e2e-shop-types.ts


+ 2 - 0
packages/core/e2e/graphql/shop-definitions.ts

@@ -80,6 +80,8 @@ export const UPDATED_ORDER_FRAGMENT = gql`
             productVariant {
                 id
             }
+            linePrice
+            linePriceWithTax
             discounts {
                 adjustmentSource
                 amount

+ 135 - 0
packages/core/e2e/money-strategy.e2e-spec.ts

@@ -0,0 +1,135 @@
+import { CompatibilityMoneyStrategy, DefaultMoneyStrategy, mergeConfig } from '@vendure/core';
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import path from 'path';
+import { ColumnOptions } from 'typeorm';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { Logger, MoneyStrategy } from '../src/index';
+
+import * as Codegen from './graphql/generated-e2e-admin-types';
+import { SortOrder } from './graphql/generated-e2e-admin-types';
+import * as CodegenShop from './graphql/generated-e2e-shop-types';
+import { AddItemToOrderMutation, AddItemToOrderMutationVariables } from './graphql/generated-e2e-shop-types';
+import { GET_PRODUCT_VARIANT_LIST } from './graphql/shared-definitions';
+import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
+
+// tslint:disable:no-non-null-assertion
+
+const orderGuard: ErrorResultGuard<CodegenShop.UpdatedOrderFragment> = createErrorResultGuard(
+    input => !!input.total,
+);
+
+class CustomMoneyStrategy implements MoneyStrategy {
+    readonly moneyColumnOptions: ColumnOptions = {
+        type: 'bigint',
+        transformer: {
+            to: (entityValue: number) => {
+                return entityValue;
+            },
+            from: (databaseValue: string): number => {
+                if (databaseValue == null) {
+                    return databaseValue;
+                }
+                const intVal = Number.parseInt(databaseValue, 10);
+                if (!Number.isSafeInteger(intVal)) {
+                    Logger.warn(`Monetary value ${databaseValue} is not a safe integer!`);
+                }
+                if (Number.isNaN(intVal)) {
+                    Logger.warn(`Monetary value ${databaseValue} is not a number!`);
+                }
+                return intVal;
+            },
+        },
+    };
+
+    round(value: number, quantity = 1): number {
+        return Math.round(value * quantity);
+    }
+}
+
+describe('Custom MoneyStrategy', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            entityOptions: {
+                moneyStrategy: new CustomMoneyStrategy(),
+            },
+        }),
+    );
+
+    let cheapVariantId: string;
+    let expensiveVariantId: string;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-money-handling.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('check initial prices', async () => {
+        const { productVariants } = await adminClient.query<
+            Codegen.GetProductVariantListQuery,
+            Codegen.GetProductVariantListQueryVariables
+        >(GET_PRODUCT_VARIANT_LIST, {
+            options: {
+                sort: {
+                    price: SortOrder.ASC,
+                },
+            },
+        });
+        expect(productVariants.items[0].price).toBe(31);
+        expect(productVariants.items[0].priceWithTax).toBe(37);
+        expect(productVariants.items[1].price).toBe(9_999_999_00);
+        expect(productVariants.items[1].priceWithTax).toBe(11_999_998_80);
+
+        cheapVariantId = productVariants.items[0].id;
+        expensiveVariantId = productVariants.items[1].id;
+    });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/838
+    it('can handle totals over 21 million', async () => {
+        await shopClient.asAnonymousUser();
+        const { addItemToOrder } = await shopClient.query<
+            AddItemToOrderMutation,
+            AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: expensiveVariantId,
+            quantity: 2,
+        });
+        orderGuard.assertSuccess(addItemToOrder);
+
+        expect(addItemToOrder.lines[0].linePrice).toBe(1_999_999_800);
+        expect(addItemToOrder.lines[0].linePriceWithTax).toBe(2_399_999_760);
+    });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/1835
+    // 31 * 1.2 = 37.2
+    // Math.round(37.2 * 10) =372
+    it('tax calculation rounds at the unit level', async () => {
+        await shopClient.asAnonymousUser();
+        const { addItemToOrder } = await shopClient.query<
+            AddItemToOrderMutation,
+            AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: cheapVariantId,
+            quantity: 10,
+        });
+        orderGuard.assertSuccess(addItemToOrder);
+
+        expect(addItemToOrder.lines[0].linePrice).toBe(310);
+        expect(addItemToOrder.lines[0].linePriceWithTax).toBe(372);
+    });
+});
+
+class CustomRoundingStrategy extends DefaultMoneyStrategy {
+    round(value: number): number {
+        return value;
+    }
+}

+ 0 - 1
packages/core/e2e/order-modification.e2e-spec.ts

@@ -1647,7 +1647,6 @@ describe('Order modification', () => {
             orderGuard.assertSuccess(order);
 
             const originalTotalWithTax = order.totalWithTax;
-
             const transitionOrderToState = await adminTransitionOrderToState(order.id, 'Modifying');
             orderGuard.assertSuccess(transitionOrderToState);
 

+ 2 - 2
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -1198,9 +1198,9 @@ describe('Promotions applied to Orders', () => {
                 });
                 orderResultGuard.assertSuccess(setOrderShippingMethod);
                 expect(setOrderShippingMethod.discounts).toEqual([]);
-                expect(setOrderShippingMethod.shipping).toBe(287);
+                expect(setOrderShippingMethod.shipping).toBe(288);
                 expect(setOrderShippingMethod.shippingWithTax).toBe(345);
-                expect(setOrderShippingMethod.total).toBe(5287);
+                expect(setOrderShippingMethod.total).toBe(5288);
                 expect(setOrderShippingMethod.totalWithTax).toBe(6345);
 
                 const { applyCouponCode } = await shopClient.query<

+ 1 - 15
packages/core/e2e/product-channel.e2e-spec.ts

@@ -6,23 +6,12 @@ import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import {
-    AssignProductsToChannel,
-    AssignProductVariantsToChannel,
-    CreateAdministrator,
-    CreateChannel,
-    CreateProduct,
-    CreateProductVariants,
-    CreateRole,
     CurrencyCode,
-    GetProductWithVariants,
     LanguageCode,
     Permission,
     ProductVariantFragment,
-    RemoveProductsFromChannel,
-    RemoveProductVariantsFromChannel,
-    UpdateChannel,
-    UpdateProduct,
 } from './graphql/generated-e2e-admin-types';
+import * as Codegen from './graphql/generated-e2e-admin-types';
 import {
     ASSIGN_PRODUCTVARIANT_TO_CHANNEL,
     ASSIGN_PRODUCT_TO_CHANNEL,
@@ -312,9 +301,6 @@ describe('ChannelAware Products and ProductVariants', () => {
                 id: product1.id,
             });
             expect(product!.channels.map(c => c.id).sort()).toEqual(['T_3']);
-            expect(product!.variants.map(v => v.price)).toEqual([
-                Math.round(product1.variants[0].price * PRICE_FACTOR),
-            ]);
             // Third Channel is configured to include taxes in price, so they should be the same.
             expect(product!.variants.map(v => v.priceWithTax)).toEqual([
                 Math.round(product1.variants[0].priceWithTax * PRICE_FACTOR),

+ 1 - 1
packages/core/src/api/common/id-codec.ts

@@ -1,6 +1,6 @@
 import { ID } from '@vendure/common/lib/shared-types';
 
-import { EntityIdStrategy } from '../../config/entity-id-strategy/entity-id-strategy';
+import { EntityIdStrategy } from '../../config/entity/entity-id-strategy';
 import { VendureEntity } from '../../entity/base/base.entity';
 
 const ID_KEYS = ['id'];

+ 2 - 1
packages/core/src/api/config/generate-list-options.ts

@@ -118,7 +118,7 @@ function createSortParameter(schema: GraphQLSchema, targetType: GraphQLObjectTyp
         fields.push(...Object.values(existingInput.getFields()));
     }
 
-    const sortableTypes = ['ID', 'String', 'Int', 'Float', 'DateTime'];
+    const sortableTypes = ['ID', 'String', 'Int', 'Float', 'DateTime', 'Money'];
     return new GraphQLInputObjectType({
         name: inputName,
         fields: fields
@@ -191,6 +191,7 @@ function createFilterParameter(schema: GraphQLSchema, targetType: GraphQLObjectT
                 return BooleanOperators;
             case 'Int':
             case 'Float':
+            case 'Money':
                 return NumberOperators;
             case 'DateTime':
                 return DateOperators;

+ 5 - 2
packages/core/src/api/config/generate-resolvers.ts

@@ -1,7 +1,7 @@
 import { IFieldResolver, IResolvers } from '@graphql-tools/utils';
 import { StockMovementType } from '@vendure/common/lib/generated-types';
-import { GraphQLSchema } from 'graphql';
-import { GraphQLDateTime, GraphQLJSON } from 'graphql-scalars';
+import { GraphQLFloat, GraphQLSchema } from 'graphql';
+import { GraphQLDateTime, GraphQLJSON, GraphQLSafeInt } from 'graphql-scalars';
 import { GraphQLUpload } from 'graphql-upload';
 
 import { REQUEST_CONTEXT_KEY } from '../../common/constants';
@@ -18,6 +18,8 @@ import { CustomFieldRelationResolverService } from '../common/custom-field-relat
 import { ApiType } from '../common/get-api-type';
 import { RequestContext } from '../common/request-context';
 
+import { GraphQLMoney } from './money-scalar';
+
 /**
  * @description
  * Generates additional resolvers required for things like resolution of union types,
@@ -82,6 +84,7 @@ export function generateResolvers(
     const commonResolvers = {
         JSON: GraphQLJSON,
         DateTime: GraphQLDateTime,
+        Money: GraphQLMoney,
         Node: dummyResolveType,
         PaginatedList: dummyResolveType,
         Upload: (GraphQLUpload as any) || dummyResolveType,

+ 68 - 0
packages/core/src/api/config/money-scalar.ts

@@ -0,0 +1,68 @@
+import { GraphQLError, GraphQLScalarType, Kind, print } from 'graphql';
+import { inspect } from 'graphql/jsutils/inspect';
+
+export function isObjectLike(value: unknown): value is { [key: string]: unknown } {
+    // tslint:disable-next-line:triple-equals
+    return typeof value == 'object' && value !== null;
+}
+
+// Support serializing objects with custom valueOf() or toJSON() functions -
+// a common way to represent a complex value which can be represented as
+// a string (ex: MongoDB id objects).
+function serializeObject(outputValue: unknown): unknown {
+    if (isObjectLike(outputValue)) {
+        if (typeof outputValue.valueOf === 'function') {
+            const valueOfResult = outputValue.valueOf();
+            if (!isObjectLike(valueOfResult)) {
+                return valueOfResult;
+            }
+        }
+        if (typeof outputValue.toJSON === 'function') {
+            return outputValue.toJSON();
+        }
+    }
+    return outputValue;
+}
+
+/**
+ * @description
+ * The Money scalar is used to represent monetary values in the GraphQL API. It is based on the native `Float` scalar.
+ */
+export const GraphQLMoney = new GraphQLScalarType<number>({
+    name: 'Money',
+    description:
+        'The `Money` scalar type represents monetary values and supports signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).',
+    serialize(outputValue) {
+        const coercedValue = serializeObject(outputValue);
+
+        if (typeof coercedValue === 'boolean') {
+            return coercedValue ? 1 : 0;
+        }
+
+        let num = coercedValue;
+        if (typeof coercedValue === 'string' && coercedValue !== '') {
+            num = Number(coercedValue);
+        }
+
+        if (typeof num !== 'number' || !Number.isFinite(num)) {
+            throw new GraphQLError(`Money cannot represent non numeric value: ${inspect(coercedValue)}`);
+        }
+        return num;
+    },
+
+    parseValue(inputValue) {
+        if (typeof inputValue !== 'number' || !Number.isFinite(inputValue)) {
+            throw new GraphQLError(`Money cannot represent non numeric value: ${inspect(inputValue)}`);
+        }
+        return inputValue;
+    },
+
+    parseLiteral(valueNode) {
+        if (valueNode.kind !== Kind.FLOAT && valueNode.kind !== Kind.INT) {
+            throw new GraphQLError(`Money cannot represent non numeric value: ${print(valueNode)}`, {
+                nodes: valueNode,
+            });
+        }
+        return parseFloat(valueNode.value);
+    },
+});

+ 1 - 1
packages/core/src/api/schema/admin-api/order-admin.type.graphql

@@ -27,7 +27,7 @@ type OrderModification implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
-    priceChange: Int!
+    priceChange: Money!
     note: String!
     lines: [OrderModificationLine!]!
     surcharges: [Surcharge!]

+ 3 - 3
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -102,8 +102,8 @@ input CancelOrderInput {
 
 input RefundOrderInput {
     lines: [OrderLineInput!]!
-    shipping: Int!
-    adjustment: Int!
+    shipping: Money!
+    adjustment: Money!
     paymentId: ID!
     reason: String
 }
@@ -184,7 +184,7 @@ input OrderLineInput {
 input SurchargeInput {
     description: String!
     sku: String
-    price: Int!
+    price: Money!
     priceIncludesTax: Boolean!
     taxRate: Float
     taxDescription: String

+ 2 - 2
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -121,7 +121,7 @@ input CreateProductVariantInput {
     translations: [ProductVariantTranslationInput!]!
     facetValueIds: [ID!]
     sku: String!
-    price: Int
+    price: Money
     taxCategoryId: ID
     optionIds: [ID!]
     featuredAssetId: ID
@@ -140,7 +140,7 @@ input UpdateProductVariantInput {
     facetValueIds: [ID!]
     sku: String
     taxCategoryId: ID
-    price: Int
+    price: Money
     featuredAssetId: ID
     assetIds: [ID!]
     stockOnHand: Int

+ 2 - 2
packages/core/src/api/schema/admin-api/shipping-method.api.graphql

@@ -67,7 +67,7 @@ type TestShippingMethodResult {
 }
 
 type TestShippingMethodQuote {
-    price: Int!
-    priceWithTax: Int!
+    price: Money!
+    priceWithTax: Money!
     metadata: JSON
 }

+ 6 - 3
packages/core/src/api/schema/common/common-types.graphql

@@ -3,6 +3,9 @@ scalar JSON
 scalar DateTime
 scalar Upload
 
+# Our custom scalars
+scalar Money
+
 interface PaginatedList {
     items: [Node!]!
     totalItems: Int!
@@ -21,7 +24,7 @@ type Adjustment {
     adjustmentSource: String!
     type: AdjustmentType!
     description: String!
-    amount: Int!
+    amount: Money!
     data: JSON
 }
 
@@ -233,8 +236,8 @@ type Success {
 
 type ShippingMethodQuote {
     id: ID!
-    price: Int!
-    priceWithTax: Int!
+    price: Money!
+    priceWithTax: Money!
     code: String!
     name: String!
     description: String!

+ 43 - 43
packages/core/src/api/schema/common/order.type.graphql

@@ -44,21 +44,21 @@ type Order implements Node {
     To get a total of all OrderLines which does not account for prorated discounts, use the
     sum of `OrderLine.discountedLinePrice` values.
     """
-    subTotal: Int!
+    subTotal: Money!
     "Same as subTotal, but inclusive of tax"
-    subTotalWithTax: Int!
+    subTotalWithTax: Money!
     currencyCode: CurrencyCode!
     shippingLines: [ShippingLine!]!
-    shipping: Int!
-    shippingWithTax: Int!
+    shipping: Money!
+    shippingWithTax: Money!
     """
     Equal to subTotal plus shipping
     """
-    total: Int!
+    total: Money!
     """
     The final payable amount. Equal to subTotalWithTax plus shippingWithTax
     """
-    totalWithTax: Int!
+    totalWithTax: Money!
     """
     A summary of the taxes being applied to this Order
     """
@@ -76,9 +76,9 @@ type OrderTaxSummary {
     "The taxRate as a percentage"
     taxRate: Float!
     "The total net price or OrderItems to which this taxRate applies"
-    taxBase: Int!
+    taxBase: Money!
     "The total tax being applied to the Order at this taxRate"
-    taxTotal: Int!
+    taxTotal: Money!
 }
 
 type OrderAddress {
@@ -102,10 +102,10 @@ type OrderList implements PaginatedList {
 type ShippingLine {
     id: ID!
     shippingMethod: ShippingMethod!
-    price: Int!
-    priceWithTax: Int!
-    discountedPrice: Int!
-    discountedPriceWithTax: Int!
+    price: Money!
+    priceWithTax: Money!
+    discountedPrice: Money!
+    discountedPriceWithTax: Money!
     discounts: [Discount!]!
 }
 
@@ -113,8 +113,8 @@ type Discount {
     adjustmentSource: String!
     type: AdjustmentType!
     description: String!
-    amount: Int!
-    amountWithTax: Int!
+    amount: Money!
+    amountWithTax: Money!
 }
 
 type OrderItem implements Node {
@@ -123,9 +123,9 @@ type OrderItem implements Node {
     updatedAt: DateTime!
     cancelled: Boolean!
     "The price of a single unit, excluding tax and discounts"
-    unitPrice: Int!
+    unitPrice: Money!
     "The price of a single unit, including tax but excluding discounts"
-    unitPriceWithTax: Int!
+    unitPriceWithTax: Money!
     """
     The price of a single unit including discounts, excluding tax.
 
@@ -134,18 +134,18 @@ type OrderItem implements Node {
     correct price to display to customers to avoid confusion
     about the internal handling of distributed Order-level discounts.
     """
-    discountedUnitPrice: Int!
+    discountedUnitPrice: Money!
     "The price of a single unit including discounts and tax"
-    discountedUnitPriceWithTax: Int!
+    discountedUnitPriceWithTax: Money!
     """
     The actual unit price, taking into account both item discounts _and_ prorated (proportionally-distributed)
     Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
     and refund calculations.
     """
-    proratedUnitPrice: Int!
+    proratedUnitPrice: Money!
     "The proratedUnitPrice including tax"
-    proratedUnitPriceWithTax: Int!
-    unitTax: Int!
+    proratedUnitPriceWithTax: Money!
+    unitTax: Money!
     taxRate: Float!
     adjustments: [Adjustment!]!
     taxLines: [TaxLine!]!
@@ -160,17 +160,17 @@ type OrderLine implements Node {
     productVariant: ProductVariant!
     featuredAsset: Asset
     "The price of a single unit, excluding tax and discounts"
-    unitPrice: Int!
+    unitPrice: Money!
     "The price of a single unit, including tax but excluding discounts"
-    unitPriceWithTax: Int!
+    unitPriceWithTax: Money!
     """
     Non-zero if the unitPrice has changed since it was initially added to Order
     """
-    unitPriceChangeSinceAdded: Int!
+    unitPriceChangeSinceAdded: Money!
     """
     Non-zero if the unitPriceWithTax has changed since it was initially added to Order
     """
-    unitPriceWithTaxChangeSinceAdded: Int!
+    unitPriceWithTaxChangeSinceAdded: Money!
     """
     The price of a single unit including discounts, excluding tax.
 
@@ -179,17 +179,17 @@ type OrderLine implements Node {
     correct price to display to customers to avoid confusion
     about the internal handling of distributed Order-level discounts.
     """
-    discountedUnitPrice: Int!
+    discountedUnitPrice: Money!
     "The price of a single unit including discounts and tax"
-    discountedUnitPriceWithTax: Int!
+    discountedUnitPriceWithTax: Money!
     """
     The actual unit price, taking into account both item discounts _and_ prorated (proportionally-distributed)
     Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
     and refund calculations.
     """
-    proratedUnitPrice: Int!
+    proratedUnitPrice: Money!
     "The proratedUnitPrice including tax"
-    proratedUnitPriceWithTax: Int!
+    proratedUnitPriceWithTax: Money!
     quantity: Int!
     "The quantity at the time the Order was placed"
     orderPlacedQuantity: Int!
@@ -197,25 +197,25 @@ type OrderLine implements Node {
     """
     The total price of the line excluding tax and discounts.
     """
-    linePrice: Int!
+    linePrice: Money!
     """
     The total price of the line including tax but excluding discounts.
     """
-    linePriceWithTax: Int!
+    linePriceWithTax: Money!
     "The price of the line including discounts, excluding tax"
-    discountedLinePrice: Int!
+    discountedLinePrice: Money!
     "The price of the line including discounts and tax"
-    discountedLinePriceWithTax: Int!
+    discountedLinePriceWithTax: Money!
     """
     The actual line price, taking into account both item discounts _and_ prorated (proportionally-distributed)
     Order-level discounts. This value is the true economic value of the OrderLine, and is used in tax
     and refund calculations.
     """
-    proratedLinePrice: Int!
+    proratedLinePrice: Money!
     "The proratedLinePrice including tax"
-    proratedLinePriceWithTax: Int!
+    proratedLinePriceWithTax: Money!
     "The total tax on this line"
-    lineTax: Int!
+    lineTax: Money!
     discounts: [Discount!]!
     taxLines: [TaxLine!]!
     order: Order!
@@ -227,7 +227,7 @@ type Payment implements Node {
     createdAt: DateTime!
     updatedAt: DateTime!
     method: String!
-    amount: Int!
+    amount: Money!
     state: String!
     transactionId: String
     errorMessage: String
@@ -247,10 +247,10 @@ type Refund implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
-    items: Int!
-    shipping: Int!
-    adjustment: Int!
-    total: Int!
+    items: Money!
+    shipping: Money!
+    adjustment: Money!
+    total: Money!
     method: String
     state: String!
     transactionId: String
@@ -286,7 +286,7 @@ type Surcharge implements Node {
     description: String!
     sku: String
     taxLines: [TaxLine!]!
-    price: Int!
-    priceWithTax: Int!
+    price: Money!
+    priceWithTax: Money!
     taxRate: Float!
 }

+ 3 - 3
packages/core/src/api/schema/common/product-search.type.graphql

@@ -59,11 +59,11 @@ union SearchResultPrice = PriceRange | SinglePrice
 
 "The price value where the result has a single price"
 type SinglePrice {
-    value: Int!
+    value: Money!
 }
 
 "The price range where the result has more than one price"
 type PriceRange {
-    min: Int!
-    max: Int!
+    min: Money!
+    max: Money!
 }

+ 2 - 2
packages/core/src/api/schema/common/product.type.graphql

@@ -49,9 +49,9 @@ type ProductVariant implements Node {
     name: String!
     featuredAsset: Asset
     assets: [Asset!]!
-    price: Int!
+    price: Money!
     currencyCode: CurrencyCode!
-    priceWithTax: Int!
+    priceWithTax: Money!
     stockLevel: String!
     taxRateApplied: TaxRate!
     taxCategory: TaxCategory!

+ 3 - 0
packages/core/src/bootstrap.ts

@@ -15,6 +15,7 @@ import { coreEntitiesMap } from './entity/entities';
 import { registerCustomEntityFields } from './entity/register-custom-entity-fields';
 import { runEntityMetadataModifiers } from './entity/run-entity-metadata-modifiers';
 import { setEntityIdStrategy } from './entity/set-entity-id-strategy';
+import { setMoneyStrategy } from './entity/set-money-strategy';
 import { validateCustomFieldsConfig } from './entity/validate-custom-fields-config';
 import { getConfigurationFunction, getEntitiesFromPlugins } from './plugin/plugin-metadata';
 import { getPluginStartupMessages } from './plugin/plugin-utils';
@@ -142,6 +143,8 @@ export async function preBootstrapConfig(
     let config = getConfig();
     const entityIdStrategy = config.entityOptions.entityIdStrategy ?? config.entityIdStrategy;
     setEntityIdStrategy(entityIdStrategy, entities);
+    const moneyStrategy = config.entityOptions.moneyStrategy;
+    setMoneyStrategy(moneyStrategy, entities);
     const customFieldValidationResult = validateCustomFieldsConfig(config.customFields, entities);
     if (!customFieldValidationResult.valid) {
         process.exitCode = 1;

+ 11 - 0
packages/core/src/common/round-money.ts

@@ -0,0 +1,11 @@
+import { getConfig } from '../config/config-helpers';
+import { MoneyStrategy } from '../config/entity/money-strategy';
+
+let moneyStrategy: MoneyStrategy;
+
+export function roundMoney(value: number, quantity = 1): number {
+    if (!moneyStrategy) {
+        moneyStrategy = getConfig().entityOptions.moneyStrategy;
+    }
+    return moneyStrategy.round(value, quantity);
+}

+ 2 - 2
packages/core/src/common/tax-utils.ts

@@ -2,7 +2,7 @@
  * Returns the tax component of a given gross price.
  */
 export function taxComponentOf(grossPrice: number, taxRatePc: number): number {
-    return Math.round(grossPrice - grossPrice / ((100 + taxRatePc) / 100));
+    return grossPrice - grossPrice / ((100 + taxRatePc) / 100);
 }
 
 /**
@@ -16,7 +16,7 @@ export function netPriceOf(grossPrice: number, taxRatePc: number): number {
  * Returns the tax applicable to the given net price.
  */
 export function taxPayableOn(netPrice: number, taxRatePc: number): number {
-    return Math.round(netPrice * (taxRatePc / 100));
+    return netPrice * (taxRatePc / 100);
 }
 
 /**

+ 4 - 3
packages/core/src/config/catalog/default-product-variant-price-calculation-strategy.spec.ts

@@ -1,3 +1,4 @@
+import { roundMoney } from '../../common/round-money';
 import {
     createRequestContext,
     MockTaxRateService,
@@ -146,7 +147,7 @@ describe('DefaultProductVariantPriceCalculationStrategy', () => {
             });
 
             expect(result).toEqual({
-                price: taxRateDefaultStandard.netPriceOf(inputPrice),
+                price: roundMoney(taxRateDefaultStandard.netPriceOf(inputPrice)),
                 priceIncludesTax: false,
             });
         });
@@ -161,7 +162,7 @@ describe('DefaultProductVariantPriceCalculationStrategy', () => {
             });
 
             expect(result).toEqual({
-                price: taxRateDefaultReduced.netPriceOf(inputPrice),
+                price: roundMoney(taxRateDefaultReduced.netPriceOf(inputPrice)),
                 priceIncludesTax: false,
             });
         });
@@ -176,7 +177,7 @@ describe('DefaultProductVariantPriceCalculationStrategy', () => {
             });
 
             expect(result).toEqual({
-                price: taxRateDefaultStandard.netPriceOf(inputPrice),
+                price: roundMoney(taxRateDefaultStandard.netPriceOf(inputPrice)),
                 priceIncludesTax: false,
             });
         });

+ 2 - 1
packages/core/src/config/catalog/default-product-variant-price-calculation-strategy.ts

@@ -1,4 +1,5 @@
 import { Injector } from '../../common/injector';
+import { roundMoney } from '../../common/round-money';
 import { PriceCalculationResult } from '../../common/types/common-types';
 import { idsAreEqual } from '../../common/utils';
 import { TaxRateService } from '../../service/services/tax-rate.service';
@@ -36,7 +37,7 @@ export class DefaultProductVariantPriceCalculationStrategy implements ProductVar
                     ctx.channel.defaultTaxZone,
                     taxCategory,
                 );
-                price = taxRateForDefaultZone.netPriceOf(inputPrice);
+                price = roundMoney(taxRateForDefaultZone.netPriceOf(inputPrice));
             }
         }
 

+ 1 - 1
packages/core/src/config/config.service.mock.ts

@@ -2,7 +2,7 @@ import { VendureEntity } from '../entity/base/base.entity';
 import { MockClass } from '../testing/testing-types';
 
 import { ConfigService } from './config.service';
-import { EntityIdStrategy, PrimaryKeyType } from './entity-id-strategy/entity-id-strategy';
+import { EntityIdStrategy, PrimaryKeyType } from './entity/entity-id-strategy';
 
 export class MockConfigService implements MockClass<ConfigService> {
     apiOptions = {

+ 1 - 1
packages/core/src/config/config.service.ts

@@ -4,7 +4,7 @@ import { ConnectionOptions } from 'typeorm';
 
 import { getConfig } from './config-helpers';
 import { CustomFields } from './custom-field/custom-field-types';
-import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
+import { EntityIdStrategy } from './entity/entity-id-strategy';
 import { Logger, VendureLogger } from './logger/vendure-logger';
 import {
     ApiOptions,

+ 3 - 1
packages/core/src/config/default-config.ts

@@ -20,7 +20,8 @@ import { defaultCollectionFilters } from './catalog/default-collection-filters';
 import { DefaultProductVariantPriceCalculationStrategy } from './catalog/default-product-variant-price-calculation-strategy';
 import { DefaultStockDisplayStrategy } from './catalog/default-stock-display-strategy';
 import { DefaultStockLocationStrategy } from './catalog/default-stock-location-strategy';
-import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
+import { AutoIncrementIdStrategy } from './entity/auto-increment-id-strategy';
+import { DefaultMoneyStrategy } from './entity/default-money-strategy';
 import { defaultFulfillmentProcess } from './fulfillment/default-fulfillment-process';
 import { manualFulfillmentHandler } from './fulfillment/manual-fulfillment-handler';
 import { DefaultLogger } from './logger/default-logger';
@@ -119,6 +120,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         type: 'mysql',
     },
     entityOptions: {
+        moneyStrategy: new DefaultMoneyStrategy(),
         channelCacheTtl: 30000,
         zoneCacheTtl: 30000,
         taxRateCacheTtl: 30000,

+ 0 - 0
packages/core/src/config/entity-id-strategy/auto-increment-id-strategy.ts → packages/core/src/config/entity/auto-increment-id-strategy.ts


+ 0 - 0
packages/core/src/config/entity-id-strategy/base64-id-strategy.ts → packages/core/src/config/entity/base64-id-strategy.ts


+ 44 - 0
packages/core/src/config/entity/bigint-money-strategy.ts

@@ -0,0 +1,44 @@
+import { ColumnOptions } from 'typeorm';
+
+import { Logger } from '../logger/vendure-logger';
+
+import { MoneyStrategy } from './money-strategy';
+
+/**
+ * @description
+ * A {@link MoneyStrategy} that stores monetary values as a `bigint` type in the database, which
+ * allows values up to ~9 quadrillion to be stored (limited by JavaScript's `MAX_SAFE_INTEGER` limit).
+ *
+ * This strategy also slightly differs in the way rounding is performed, with rounding being done _after_
+ * multiplying the unit price, rather than before (as is the case with the {@link DefaultMoneyStrategy}.
+ *
+ * @docsCategory money
+ * @since 2.0.0
+ */
+export class BigIntMoneyStrategy implements MoneyStrategy {
+    readonly moneyColumnOptions: ColumnOptions = {
+        type: 'bigint',
+        transformer: {
+            to: (entityValue: number) => {
+                return entityValue;
+            },
+            from: (databaseValue: string): number => {
+                if (databaseValue == null) {
+                    return databaseValue;
+                }
+                const intVal = Number.parseInt(databaseValue, 10);
+                if (!Number.isSafeInteger(intVal)) {
+                    Logger.warn(`Monetary value ${databaseValue} is not a safe integer!`);
+                }
+                if (Number.isNaN(intVal)) {
+                    Logger.warn(`Monetary value ${databaseValue} is not a number!`);
+                }
+                return intVal;
+            },
+        },
+    };
+
+    round(value: number, quantity = 1): number {
+        return Math.round(value * quantity);
+    }
+}

+ 23 - 0
packages/core/src/config/entity/default-money-strategy.ts

@@ -0,0 +1,23 @@
+import { ColumnOptions } from 'typeorm';
+
+import { Logger } from '../logger/vendure-logger';
+
+import { MoneyStrategy } from './money-strategy';
+
+/**
+ * @description
+ * A {@link MoneyStrategy} that stores monetary values as a `int` type in the database.
+ * The storage configuration and rounding logic replicates the behaviour of Vendure pre-2.0.
+ *
+ * @docsCategory money
+ * @since 2.0.0
+ */
+export class DefaultMoneyStrategy implements MoneyStrategy {
+    readonly moneyColumnOptions: ColumnOptions = {
+        type: 'int',
+    };
+
+    round(value: number, quantity = 1): number {
+        return Math.round(value) * quantity;
+    }
+}

+ 0 - 0
packages/core/src/config/entity-id-strategy/entity-id-strategy.ts → packages/core/src/config/entity/entity-id-strategy.ts


+ 64 - 0
packages/core/src/config/entity/money-strategy.ts

@@ -0,0 +1,64 @@
+import { ColumnOptions } from 'typeorm';
+
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+
+/**
+ * @description
+ * The MoneyStrategy defines how monetary values are stored and manipulated. The MoneyStrategy
+ * is defined in {@link EntityOptions}:
+ *
+ * @example
+ * ```TypeScript
+ * const config: VendureConfig = {
+ *   entityOptions: {
+ *     moneyStrategy: new MyCustomMoneyStrategy(),
+ *   }
+ * };
+ * ```
+ *
+ * ## Range
+ *
+ * The {@link DefaultMoneyStrategy} uses an `int` field in the database, which puts an
+ * effective limit of ~21.4 million on any stored value. For certain use cases
+ * (e.g. business sales with very high amounts, or currencies with very large
+ * denominations), this may cause issues. In this case, you can use the
+ * {@link BigIntMoneyStrategy} which will use the `bigint` type to store monetary values,
+ * giving an effective upper limit of over 9 quadrillion.
+ *
+ * ## Precision & rounding
+ *
+ * Both the `DefaultMoneyStrategy` and `BigIntMoneyStrategy` store monetary values as integers, representing
+ * the price in the minor units of the currency (i.e. _cents_ in USD or _pennies_ in GBP).
+ *
+ * In certain use-cases, it may be required that fractions of a cent or penny be supported. In this case,
+ * the solution would be to define a custom MoneyStrategy which uses a non-integer data type for storing
+ * the value in the database, and defines a `round()` implementation which allows decimal places to be kept.
+ *
+ * @docsCategory money
+ * @since 2.0.0
+ */
+export interface MoneyStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Defines the TypeORM column used to store monetary values.
+     */
+    readonly moneyColumnOptions: ColumnOptions;
+
+    /**
+     * @description
+     * Defines the logic used to round monetary values. For instance, the default behavior
+     * in the {@link DefaultMoneyStrategy} is to round the value, then multiply.
+     *
+     * ```TypeScript
+     * return Math.round(value) * quantity;
+     * ```
+     *
+     * However, it may be desirable to instead round only _after_ the unit amount has been
+     * multiplied. In this case you can define a custom strategy with logic like this:
+     *
+     * ```TypeScript
+     * return Math.round(value * quantity);
+     * ```
+     */
+    round(value: number, quantity?: number): number;
+}

+ 0 - 0
packages/core/src/config/entity-id-strategy/uuid-id-strategy.ts → packages/core/src/config/entity/uuid-id-strategy.ts


+ 6 - 3
packages/core/src/config/index.ts

@@ -20,9 +20,12 @@ export * from './config.module';
 export * from './config.service';
 export * from './custom-field/custom-field-types';
 export * from './default-config';
-export * from './entity-id-strategy/auto-increment-id-strategy';
-export * from './entity-id-strategy/entity-id-strategy';
-export * from './entity-id-strategy/uuid-id-strategy';
+export * from './entity/auto-increment-id-strategy';
+export * from './entity/default-money-strategy';
+export * from './entity/bigint-money-strategy';
+export * from './entity/entity-id-strategy';
+export * from './entity/money-strategy';
+export * from './entity/uuid-id-strategy';
 export * from './entity-metadata/add-foreign-key-indices';
 export * from './entity-metadata/entity-metadata-modifier';
 export * from './fulfillment/default-fulfillment-process';

+ 10 - 1
packages/core/src/config/vendure-config.ts

@@ -21,8 +21,9 @@ import { ProductVariantPriceCalculationStrategy } from './catalog/product-varian
 import { StockDisplayStrategy } from './catalog/stock-display-strategy';
 import { StockLocationStrategy } from './catalog/stock-location-strategy';
 import { CustomFields } from './custom-field/custom-field-types';
-import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { EntityMetadataModifier } from './entity-metadata/entity-metadata-modifier';
+import { EntityIdStrategy } from './entity/entity-id-strategy';
+import { MoneyStrategy } from './entity/money-strategy';
 import { FulfillmentHandler } from './fulfillment/fulfillment-handler';
 import { FulfillmentProcess } from './fulfillment/fulfillment-process';
 import { JobQueueStrategy } from './job-queue/job-queue-strategy';
@@ -923,6 +924,14 @@ export interface EntityOptions {
      * @default AutoIncrementIdStrategy
      */
     entityIdStrategy?: EntityIdStrategy<any>;
+    /**
+     * @description
+     * Defines the strategy used to store and round monetary values.
+     *
+     * @since 2.0.0
+     * @default DefaultMoneyStrategy
+     */
+    moneyStrategy?: MoneyStrategy;
     /**
      * @description
      * Channels get cached in-memory as they are accessed very frequently. This

+ 51 - 0
packages/core/src/entity/money.decorator.ts

@@ -0,0 +1,51 @@
+import { Type } from '@vendure/common/lib/shared-types';
+import { Column } from 'typeorm';
+import { ColumnOptions } from 'typeorm/decorator/options/ColumnOptions';
+
+import { Logger } from '../config/logger/vendure-logger';
+
+interface MoneyColumnOptions {
+    default?: number;
+    /** Whether the field is nullable. Defaults to false */
+    nullable?: boolean;
+}
+
+interface MoneyColumnConfig {
+    name: string;
+    entity: any;
+    options?: MoneyColumnOptions;
+}
+
+const moneyColumnRegistry = new Map<any, MoneyColumnConfig[]>();
+
+/**
+ * @description
+ * Use this decorator for any entity field that is storing a monetary value.
+ * This allows the column type to be defined by the configured {@link MoneyStrategy}.
+ *
+ * @docsCategory money
+ * @since 2.0.0
+ */
+export function Money(options?: MoneyColumnOptions) {
+    return (entity: any, propertyName: string) => {
+        const idColumns = moneyColumnRegistry.get(entity);
+        const entry = { name: propertyName, entity, options };
+        if (idColumns) {
+            idColumns.push(entry);
+        } else {
+            moneyColumnRegistry.set(entity, [entry]);
+        }
+    };
+}
+
+/**
+ * @description
+ * Returns any columns on the entity which have been decorated with the {@link EntityId}
+ * decorator.
+ */
+export function getMoneyColumnsFor(entityType: Type<any>): MoneyColumnConfig[] {
+    const match = Array.from(moneyColumnRegistry.entries()).find(
+        ([entity, columns]) => entity.constructor === entityType,
+    );
+    return match ? match[1] : [];
+}

+ 108 - 59
packages/core/src/entity/order-line/order-line.entity.ts

@@ -4,6 +4,7 @@ import { summate } from '@vendure/common/lib/shared-utils';
 import { Column, Entity, Index, ManyToOne, OneToOne } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { roundMoney } from '../../common/round-money';
 import { grossPriceOf, netPriceOf } from '../../common/tax-utils';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { Asset } from '../asset/asset.entity';
@@ -11,6 +12,7 @@ import { VendureEntity } from '../base/base.entity';
 import { Channel } from '../channel/channel.entity';
 import { CustomOrderLineFields } from '../custom-entity-fields';
 import { EntityId } from '../entity-id.decorator';
+import { Money } from '../money.decorator';
 import { Order } from '../order/order.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
 import { ShippingLine } from '../shipping-line/shipping-line.entity';
@@ -91,7 +93,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      * `listPrice`, except when the ProductVariant price has changed in the meantime and a re-calculation of
      * the Order has been performed.
      */
-    @Column({ nullable: true })
+    @Money({ nullable: true })
     initialListPrice: number;
 
     /**
@@ -99,7 +101,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      * This is the price as listed by the ProductVariant (and possibly modified by the {@link OrderItemPriceCalculationStrategy}),
      * which, depending on the current Channel, may or may not include tax.
      */
-    @Column()
+    @Money()
     listPrice: number;
 
     /**
@@ -127,7 +129,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get unitPrice(): number {
-        return this.listPriceIncludesTax ? netPriceOf(this.listPrice, this.taxRate) : this.listPrice;
+        return roundMoney(this._unitPrice());
     }
 
     /**
@@ -136,7 +138,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get unitPriceWithTax(): number {
-        return this.listPriceIncludesTax ? this.listPrice : grossPriceOf(this.listPrice, this.taxRate);
+        return roundMoney(this._unitPriceWithTax());
     }
 
     /**
@@ -149,7 +151,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
         const initialPrice = listPriceIncludesTax
             ? netPriceOf(initialListPrice, this.taxRate)
             : initialListPrice;
-        return this.unitPrice - initialPrice;
+        return roundMoney(this._unitPrice() - initialPrice);
     }
 
     /**
@@ -162,7 +164,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
         const initialPriceWithTax = listPriceIncludesTax
             ? initialListPrice
             : grossPriceOf(initialListPrice, this.taxRate);
-        return this.unitPriceWithTax - initialPriceWithTax;
+        return roundMoney(this._unitPriceWithTax() - initialPriceWithTax);
     }
 
     /**
@@ -176,8 +178,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get discountedUnitPrice(): number {
-        const result = this.listPrice + this.getAdjustmentsTotal(AdjustmentType.PROMOTION);
-        return this.listPriceIncludesTax ? netPriceOf(result, this.taxRate) : result;
+        return roundMoney(this._discountedUnitPrice());
     }
 
     /**
@@ -186,19 +187,18 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get discountedUnitPriceWithTax(): number {
-        const result = this.listPrice + this.getAdjustmentsTotal(AdjustmentType.PROMOTION);
-        return this.listPriceIncludesTax ? result : grossPriceOf(result, this.taxRate);
+        return roundMoney(this._discountedUnitPriceWithTax());
     }
 
     /**
      * @description
      * The actual unit price, taking into account both item discounts _and_ prorated (proportionally-distributed)
-     * Order-level discounts. This value is the true economic value of the a single unit in this OrderLine, and is used in tax
+     * Order-level discounts. This value is the true economic value of a single unit in this OrderLine, and is used in tax
      * and refund calculations.
      */
     @Calculated()
     get proratedUnitPrice(): number {
-        return Math.round(this._proratedUnitPrice());
+        return roundMoney(this._proratedUnitPrice());
     }
 
     /**
@@ -207,29 +207,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get proratedUnitPriceWithTax(): number {
-        return Math.round(this._proratedUnitPriceWithTax());
-    }
-
-    /**
-     * @description
-     * Calculates the prorated unit price, excluding tax. This function performs no
-     * rounding, so before being exposed publicly via the GraphQL API, the returned value
-     * needs to be rounded to ensure it is an integer.
-     */
-    private _proratedUnitPrice(): number {
-        const result = this.listPrice + this.getAdjustmentsTotal();
-        return this.listPriceIncludesTax ? netPriceOf(result, this.taxRate) : result;
-    }
-
-    /**
-     * @description
-     * Calculates the prorated unit price, including tax. This function performs no
-     * rounding, so before being exposed publicly via the GraphQL API, the returned value
-     * needs to be rounded to ensure it is an integer.
-     */
-    private _proratedUnitPriceWithTax(): number {
-        const result = this.listPrice + this.getAdjustmentsTotal();
-        return this.listPriceIncludesTax ? result : grossPriceOf(result, this.taxRate);
+        return roundMoney(this._proratedUnitPriceWithTax());
     }
 
     @Calculated()
@@ -242,20 +220,6 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
         return this.proratedUnitPriceWithTax - this.proratedUnitPrice;
     }
 
-    /**
-     * @description
-     * The total of all price adjustments. Will typically be a negative number due to discounts.
-     */
-    private getAdjustmentsTotal(type?: AdjustmentType): number {
-        if (!this.adjustments || this.quantity === 0) {
-            return 0;
-        }
-        return this.adjustments
-            .filter(adjustment => (type ? adjustment.type === type : true))
-            .map(adjustment => adjustment.amount / Math.max(this.orderPlacedQuantity, this.quantity))
-            .reduce((total, a) => total + a, 0);
-    }
-
     @Calculated()
     get taxRate(): number {
         return summate(this.taxLines, 'taxRate');
@@ -267,7 +231,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get linePrice(): number {
-        return this.unitPrice * this.quantity;
+        return roundMoney(this._unitPrice(), this.quantity);
     }
 
     /**
@@ -276,7 +240,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get linePriceWithTax(): number {
-        return this.unitPriceWithTax * this.quantity;
+        return roundMoney(this._unitPriceWithTax(), this.quantity);
     }
 
     /**
@@ -285,7 +249,8 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get discountedLinePrice(): number {
-        return this.discountedUnitPrice * this.quantity;
+        // return roundMoney(this.linePrice + this.getLineAdjustmentsTotal(false, AdjustmentType.PROMOTION));
+        return roundMoney(this._discountedUnitPrice(), this.quantity);
     }
 
     /**
@@ -294,7 +259,10 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get discountedLinePriceWithTax(): number {
-        return this.discountedUnitPriceWithTax * this.quantity;
+        // return roundMoney(
+        //     this.linePriceWithTax + this.getLineAdjustmentsTotal(true, AdjustmentType.PROMOTION),
+        // );
+        return roundMoney(this._discountedUnitPriceWithTax(), this.quantity);
     }
 
     @Calculated()
@@ -319,8 +287,8 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
             } else {
                 groupedDiscounts.set(adjustment.adjustmentSource, {
                     ...(adjustment as Omit<Adjustment, '__typename'>),
-                    amount,
-                    amountWithTax,
+                    amount: roundMoney(amount),
+                    amountWithTax: roundMoney(amountWithTax),
                 });
             }
         }
@@ -333,7 +301,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get lineTax(): number {
-        return this.unitTax * this.quantity;
+        return this.linePriceWithTax - this.linePrice;
     }
 
     /**
@@ -344,7 +312,8 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get proratedLinePrice(): number {
-        return this._proratedUnitPrice() * this.quantity;
+        // return roundMoney(this.linePrice + this.getLineAdjustmentsTotal(false));
+        return roundMoney(this._proratedUnitPrice(), this.quantity);
     }
 
     /**
@@ -353,12 +322,13 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      */
     @Calculated()
     get proratedLinePriceWithTax(): number {
-        return this._proratedUnitPriceWithTax() * this.quantity;
+        // return roundMoney(this.linePriceWithTax + this.getLineAdjustmentsTotal(true));
+        return roundMoney(this._proratedUnitPriceWithTax(), this.quantity);
     }
 
     @Calculated()
     get proratedLineTax(): number {
-        return this.proratedUnitTax * this.quantity;
+        return this.proratedLinePriceWithTax - this.proratedLinePrice;
     }
 
     addAdjustment(adjustment: Adjustment) {
@@ -376,4 +346,83 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
             this.adjustments = this.adjustments ? this.adjustments.filter(a => a.type !== type) : [];
         }
     }
+
+    private _unitPrice(): number {
+        return this.listPriceIncludesTax ? netPriceOf(this.listPrice, this.taxRate) : this.listPrice;
+    }
+
+    private _unitPriceWithTax(): number {
+        return this.listPriceIncludesTax ? this.listPrice : grossPriceOf(this.listPrice, this.taxRate);
+    }
+
+    private _discountedUnitPrice(): number {
+        const result = this.listPrice + this.getUnitAdjustmentsTotal(AdjustmentType.PROMOTION);
+        return this.listPriceIncludesTax ? netPriceOf(result, this.taxRate) : result;
+    }
+
+    private _discountedUnitPriceWithTax(): number {
+        const result = this.listPrice + this.getUnitAdjustmentsTotal(AdjustmentType.PROMOTION);
+        return this.listPriceIncludesTax ? result : grossPriceOf(result, this.taxRate);
+    }
+
+    /**
+     * @description
+     * Calculates the prorated unit price, excluding tax. This function performs no
+     * rounding, so before being exposed publicly via the GraphQL API, the returned value
+     * needs to be rounded to ensure it is an integer.
+     */
+    private _proratedUnitPrice(): number {
+        const result = this.listPrice + this.getUnitAdjustmentsTotal();
+        return this.listPriceIncludesTax ? netPriceOf(result, this.taxRate) : result;
+    }
+
+    /**
+     * @description
+     * Calculates the prorated unit price, including tax. This function performs no
+     * rounding, so before being exposed publicly via the GraphQL API, the returned value
+     * needs to be rounded to ensure it is an integer.
+     */
+    private _proratedUnitPriceWithTax(): number {
+        const result = this.listPrice + this.getUnitAdjustmentsTotal();
+        return this.listPriceIncludesTax ? result : grossPriceOf(result, this.taxRate);
+    }
+
+    /**
+     * @description
+     * The total of all price adjustments. Will typically be a negative number due to discounts.
+     */
+    private getUnitAdjustmentsTotal(type?: AdjustmentType): number {
+        if (!this.adjustments || this.quantity === 0) {
+            return 0;
+        }
+        return this.adjustments
+            .filter(adjustment => (type ? adjustment.type === type : true))
+            .map(adjustment => adjustment.amount / Math.max(this.orderPlacedQuantity, this.quantity))
+            .reduce((total, a) => total + a, 0);
+    }
+
+    /**
+     * @description
+     * The total of all price adjustments. Will typically be a negative number due to discounts.
+     */
+    private getLineAdjustmentsTotal(withTax: boolean, type?: AdjustmentType): number {
+        if (!this.adjustments || this.quantity === 0) {
+            return 0;
+        }
+        const sum = this.adjustments
+            .filter(adjustment => (type ? adjustment.type === type : true))
+            .map(adjustment => adjustment.amount)
+            .reduce((total, a) => total + a, 0);
+        const adjustedForQuantityChanges =
+            sum * (this.quantity / Math.max(this.orderPlacedQuantity, this.quantity));
+        if (withTax) {
+            return this.listPriceIncludesTax
+                ? adjustedForQuantityChanges
+                : grossPriceOf(adjustedForQuantityChanges, this.taxRate);
+        } else {
+            return this.listPriceIncludesTax
+                ? netPriceOf(adjustedForQuantityChanges, this.taxRate)
+                : adjustedForQuantityChanges;
+        }
+    }
 }

+ 2 - 1
packages/core/src/entity/order-modification/order-modification.entity.ts

@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro
 
 import { Calculated } from '../../common/calculated-decorator';
 import { VendureEntity } from '../base/base.entity';
+import { Money } from '../money.decorator';
 import { OrderModificationLine } from '../order-line-reference/order-modification-line.entity';
 import { Order } from '../order/order.entity';
 import { Payment } from '../payment/payment.entity';
@@ -36,7 +37,7 @@ export class OrderModification extends VendureEntity {
     @OneToMany(type => Surcharge, surcharge => surcharge.orderModification)
     surcharges: Surcharge[];
 
-    @Column()
+    @Money()
     priceChange: number;
 
     @OneToOne(type => Payment)

+ 5 - 4
packages/core/src/entity/order/order.entity.ts

@@ -21,6 +21,7 @@ import { CustomOrderFields } from '../custom-entity-fields';
 import { Customer } from '../customer/customer.entity';
 import { EntityId } from '../entity-id.decorator';
 import { Fulfillment } from '../fulfillment/fulfillment.entity';
+import { Money } from '../money.decorator';
 import { OrderLine } from '../order-line/order-line.entity';
 import { OrderModification } from '../order-modification/order-modification.entity';
 import { Payment } from '../payment/payment.entity';
@@ -155,14 +156,14 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
      * To get a total of all OrderLines which does not account for prorated discounts, use the
      * sum of {@link OrderLine}'s `discountedLinePrice` values.
      */
-    @Column()
+    @Money()
     subTotal: number;
 
     /**
      * @description
      * Same as subTotal, but inclusive of tax.
      */
-    @Column()
+    @Money()
     subTotalWithTax: number;
 
     /**
@@ -176,10 +177,10 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
      * @description
      * The total of all the `shippingLines`.
      */
-    @Column({ default: 0 })
+    @Money({ default: 0 })
     shipping: number;
 
-    @Column({ default: 0 })
+    @Money({ default: 0 })
     shippingWithTax: number;
 
     @Calculated({ relations: ['lines', 'shippingLines'] })

+ 2 - 1
packages/core/src/entity/payment/payment.entity.ts

@@ -4,6 +4,7 @@ import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm';
 import { PaymentMetadata } from '../../common/types/common-types';
 import { PaymentState } from '../../service/helpers/payment-state-machine/payment-state';
 import { VendureEntity } from '../base/base.entity';
+import { Money } from '../money.decorator';
 import { Order } from '../order/order.entity';
 import { Refund } from '../refund/refund.entity';
 
@@ -22,7 +23,7 @@ export class Payment extends VendureEntity {
 
     @Column() method: string;
 
-    @Column() amount: number;
+    @Money() amount: number;
 
     @Column('varchar') state: PaymentState;
 

+ 2 - 1
packages/core/src/entity/product-variant/product-variant-price.entity.ts

@@ -3,6 +3,7 @@ import { Column, Entity, Index, ManyToOne } from 'typeorm';
 
 import { VendureEntity } from '../base/base.entity';
 import { EntityId } from '../entity-id.decorator';
+import { Money } from '../money.decorator';
 
 import { ProductVariant } from './product-variant.entity';
 
@@ -19,7 +20,7 @@ export class ProductVariantPrice extends VendureEntity {
         super(input);
     }
 
-    @Column() price: number;
+    @Money() price: number;
 
     @EntityId() channelId: ID;
 

+ 7 - 8
packages/core/src/entity/product-variant/product-variant.entity.ts

@@ -3,6 +3,7 @@ import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, Index, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { roundMoney } from '../../common/round-money';
 import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
@@ -75,7 +76,9 @@ export class ProductVariant
         if (this.listPrice == null) {
             return 0;
         }
-        return this.listPriceIncludesTax ? this.taxRateApplied.netPriceOf(this.listPrice) : this.listPrice;
+        return roundMoney(
+            this.listPriceIncludesTax ? this.taxRateApplied.netPriceOf(this.listPrice) : this.listPrice,
+        );
     }
 
     @Calculated({
@@ -89,7 +92,9 @@ export class ProductVariant
         if (this.listPrice == null) {
             return 0;
         }
-        return this.listPriceIncludesTax ? this.listPrice : this.taxRateApplied.grossPriceOf(this.listPrice);
+        return roundMoney(
+            this.listPriceIncludesTax ? this.listPrice : this.taxRateApplied.grossPriceOf(this.listPrice),
+        );
     }
 
     /**
@@ -123,12 +128,6 @@ export class ProductVariant
     @EntityId({ nullable: true })
     productId: ID;
 
-    // @Column({ default: 0 })
-    // stockOnHand: number;
-
-    // @Column({ default: 0 })
-    // stockAllocated: number;
-
     /**
      * @description
      * Specifies the value of stockOnHand at which the ProductVariant is considered

+ 4 - 3
packages/core/src/entity/promotion/promotion.entity.ts

@@ -3,6 +3,7 @@ import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
+import { roundMoney } from '../../common/round-money';
 import { AdjustmentSource } from '../../common/types/adjustment-source';
 import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { getConfig } from '../../config/config-helpers';
@@ -132,19 +133,19 @@ export class Promotion extends AdjustmentSource implements ChannelAware, SoftDel
             if (promotionAction instanceof PromotionItemAction) {
                 if (this.isOrderItemArg(args)) {
                     const { orderLine } = args;
-                    amount += Math.round(
+                    amount += roundMoney(
                         await promotionAction.execute(ctx, orderLine, action.args, state, this),
                     );
                 }
             } else if (promotionAction instanceof PromotionOrderAction) {
                 if (this.isOrderArg(args)) {
                     const { order } = args;
-                    amount += Math.round(await promotionAction.execute(ctx, order, action.args, state, this));
+                    amount += roundMoney(await promotionAction.execute(ctx, order, action.args, state, this));
                 }
             } else if (promotionAction instanceof PromotionShippingAction) {
                 if (this.isShippingArg(args)) {
                     const { shippingLine, order } = args;
-                    amount += Math.round(
+                    amount += roundMoney(
                         await promotionAction.execute(ctx, shippingLine, order, action.args, state, this),
                     );
                 }

+ 5 - 4
packages/core/src/entity/refund/refund.entity.ts

@@ -5,6 +5,7 @@ import { PaymentMetadata } from '../../common/types/common-types';
 import { RefundState } from '../../service/helpers/refund-state-machine/refund-state';
 import { VendureEntity } from '../base/base.entity';
 import { EntityId } from '../entity-id.decorator';
+import { Money } from '../money.decorator';
 import { RefundLine } from '../order-line-reference/refund-line.entity';
 import { Payment } from '../payment/payment.entity';
 
@@ -14,13 +15,13 @@ export class Refund extends VendureEntity {
         super(input);
     }
 
-    @Column() items: number;
+    @Money() items: number;
 
-    @Column() shipping: number;
+    @Money() shipping: number;
 
-    @Column() adjustment: number;
+    @Money() adjustment: number;
 
-    @Column() total: number;
+    @Money() total: number;
 
     @Column() method: string;
 

+ 1 - 1
packages/core/src/entity/set-entity-id-strategy.ts

@@ -1,7 +1,7 @@
 import { Type } from '@vendure/common/lib/shared-types';
 import { Column, PrimaryColumn, PrimaryGeneratedColumn } from 'typeorm';
 
-import { EntityIdStrategy } from '../config/entity-id-strategy/entity-id-strategy';
+import { EntityIdStrategy } from '../config/entity/entity-id-strategy';
 
 import { getIdColumnsFor, getPrimaryGeneratedIdColumn } from './entity-id.decorator';
 

+ 19 - 0
packages/core/src/entity/set-money-strategy.ts

@@ -0,0 +1,19 @@
+import { Type } from '@vendure/common/lib/shared-types';
+import { Column } from 'typeorm';
+
+import { MoneyStrategy } from '../config/entity/money-strategy';
+
+import { getMoneyColumnsFor } from './money.decorator';
+
+export function setMoneyStrategy(moneyStrategy: MoneyStrategy, entities: Array<Type<any>>) {
+    for (const EntityCtor of entities) {
+        const columnConfig = getMoneyColumnsFor(EntityCtor);
+        for (const { name, options, entity } of columnConfig) {
+            Column({
+                ...moneyStrategy.moneyColumnOptions,
+                nullable: options?.nullable ?? false,
+                default: options?.default,
+            })(entity, name);
+        }
+    }
+}

+ 18 - 10
packages/core/src/entity/shipping-line/shipping-line.entity.ts

@@ -4,9 +4,11 @@ import { summate } from '@vendure/common/lib/shared-utils';
 import { Column, Entity, Index, ManyToOne } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { roundMoney } from '../../common/round-money';
 import { grossPriceOf, netPriceOf } from '../../common/tax-utils';
 import { VendureEntity } from '../base/base.entity';
 import { EntityId } from '../entity-id.decorator';
+import { Money } from '../money.decorator';
 import { Order } from '../order/order.entity';
 import { ShippingMethod } from '../shipping-method/shipping-method.entity';
 
@@ -28,7 +30,7 @@ export class ShippingLine extends VendureEntity {
     @ManyToOne(type => Order, order => order.shippingLines)
     order: Order;
 
-    @Column()
+    @Money()
     listPrice: number;
 
     @Column()
@@ -47,19 +49,21 @@ export class ShippingLine extends VendureEntity {
 
     @Calculated()
     get priceWithTax(): number {
-        return this.listPriceIncludesTax ? this.listPrice : grossPriceOf(this.listPrice, this.taxRate);
+        return roundMoney(
+            this.listPriceIncludesTax ? this.listPrice : grossPriceOf(this.listPrice, this.taxRate),
+        );
     }
 
     @Calculated()
     get discountedPrice(): number {
         const result = this.listPrice + this.getAdjustmentsTotal();
-        return this.listPriceIncludesTax ? netPriceOf(result, this.taxRate) : result;
+        return roundMoney(this.listPriceIncludesTax ? netPriceOf(result, this.taxRate) : result);
     }
 
     @Calculated()
     get discountedPriceWithTax(): number {
         const result = this.listPrice + this.getAdjustmentsTotal();
-        return this.listPriceIncludesTax ? result : grossPriceOf(result, this.taxRate);
+        return roundMoney(this.listPriceIncludesTax ? result : grossPriceOf(result, this.taxRate));
     }
 
     @Calculated()
@@ -71,12 +75,16 @@ export class ShippingLine extends VendureEntity {
     get discounts(): Discount[] {
         return (
             this.adjustments?.map(adjustment => {
-                const amount = this.listPriceIncludesTax
-                    ? netPriceOf(adjustment.amount, this.taxRate)
-                    : adjustment.amount;
-                const amountWithTax = this.listPriceIncludesTax
-                    ? adjustment.amount
-                    : grossPriceOf(adjustment.amount, this.taxRate);
+                const amount = roundMoney(
+                    this.listPriceIncludesTax
+                        ? netPriceOf(adjustment.amount, this.taxRate)
+                        : adjustment.amount,
+                );
+                const amountWithTax = roundMoney(
+                    this.listPriceIncludesTax
+                        ? adjustment.amount
+                        : grossPriceOf(adjustment.amount, this.taxRate),
+                );
                 return {
                     ...(adjustment as Omit<Adjustment, '__typename'>),
                     amount,

+ 4 - 2
packages/core/src/entity/shipping-method/shipping-method.entity.ts

@@ -3,6 +3,7 @@ import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
+import { roundMoney } from '../../common/round-money';
 import { ChannelAware, SoftDeletable } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { getConfig } from '../../config/config-helpers';
@@ -32,7 +33,8 @@ import { ShippingMethodTranslation } from './shipping-method-translation.entity'
 @Entity()
 export class ShippingMethod
     extends VendureEntity
-    implements ChannelAware, SoftDeletable, HasCustomFields, Translatable {
+    implements ChannelAware, SoftDeletable, HasCustomFields, Translatable
+{
     private readonly allCheckers: { [code: string]: ShippingEligibilityChecker } = {};
     private readonly allCalculators: { [code: string]: ShippingCalculator } = {};
 
@@ -77,7 +79,7 @@ export class ShippingMethod
             if (response) {
                 const { price, priceIncludesTax, taxRate, metadata } = response;
                 return {
-                    price: Math.round(price),
+                    price: roundMoney(price),
                     priceIncludesTax,
                     taxRate,
                     metadata,

+ 2 - 1
packages/core/src/entity/surcharge/surcharge.entity.ts

@@ -6,6 +6,7 @@ import { Column, Entity, Index, ManyToOne } from 'typeorm';
 import { Calculated } from '../../common/calculated-decorator';
 import { grossPriceOf, netPriceOf } from '../../common/tax-utils';
 import { VendureEntity } from '../base/base.entity';
+import { Money } from '../money.decorator';
 import { OrderModification } from '../order-modification/order-modification.entity';
 import { Order } from '../order/order.entity';
 
@@ -25,7 +26,7 @@ export class Surcharge extends VendureEntity {
     @Column()
     description: string;
 
-    @Column()
+    @Money()
     listPrice: number;
 
     @Column()

+ 3 - 2
packages/core/src/plugin/default-search-plugin/entities/search-index-item.entity.ts

@@ -3,6 +3,7 @@ import { ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
 
 import { EntityId } from '../../../entity/entity-id.decorator';
+import { Money } from '../../../entity/money.decorator';
 
 @Entity()
 export class SearchIndexItem {
@@ -47,10 +48,10 @@ export class SearchIndexItem {
     @Column()
     sku: string;
 
-    @Column()
+    @Money()
     price: number;
 
-    @Column()
+    @Money()
     priceWithTax: number;
 
     @Column('simple-array')

+ 42 - 4
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -1135,10 +1135,48 @@ describe('OrderCalculator', () => {
                         fifteenPcOff$10Items,
                         $5OffOrderPromo,
                     ]);
-
-                    expect(order.subTotal).toBe(5082);
-                    expect(order.subTotalWithTax).toBe(5719);
-                    assertOrderTotalsAddUp(order);
+                    // console.table([
+                    //     {
+                    //         unitPrice: order.lines[0].unitPrice,
+                    //         linePrice: order.lines[0].linePrice,
+                    //         linePriceWithTax: order.lines[0].linePriceWithTax,
+                    //         discountedLinePrice: order.lines[0].discountedLinePrice,
+                    //         discountedLinePriceWithTax: order.lines[0].discountedLinePriceWithTax,
+                    //         proratedUnitPriceWithTax: order.lines[0].proratedUnitPriceWithTax,
+                    //         proratedLinePriceWithTax: order.lines[0].proratedLinePriceWithTax,
+                    //     },
+                    //     {
+                    //         unitPrice: order.lines[1].unitPrice,
+                    //         linePrice: order.lines[1].linePrice,
+                    //         linePriceWithTax: order.lines[1].linePriceWithTax,
+                    //         discountedLinePrice: order.lines[1].discountedLinePrice,
+                    //         discountedLinePriceWithTax: order.lines[1].discountedLinePriceWithTax,
+                    //         proratedUnitPriceWithTax: order.lines[1].proratedUnitPriceWithTax,
+                    //         proratedLinePriceWithTax: order.lines[1].proratedLinePriceWithTax,
+                    //     },
+                    //     {
+                    //         unitPrice: order.lines[2].unitPrice,
+                    //         linePrice: order.lines[2].linePrice,
+                    //         linePriceWithTax: order.lines[2].linePriceWithTax,
+                    //         discountedLinePrice: order.lines[2].discountedLinePrice,
+                    //         discountedLinePriceWithTax: order.lines[2].discountedLinePriceWithTax,
+                    //         proratedUnitPriceWithTax: order.lines[2].proratedUnitPriceWithTax,
+                    //         proratedLinePriceWithTax: order.lines[2].proratedLinePriceWithTax,
+                    //     },
+                    // ]);
+
+                    // Note: This combination produces slight discrepancies when using the rounding method
+                    // of the DefaultMoneyStrategy - i.e. "round then multiply". When using a strategy
+                    // of "multiply then round", we would expect the following:
+                    // ```
+                    // expect(order.subTotal).toBe(5082);
+                    // expect(order.subTotalWithTax).toBe(5719);
+                    // assertOrderTotalsAddUp(order);
+                    // ```
+                    // However, there is always a tradeoff when using integer precision with compounding
+                    // fractional multiplication.
+                    expect(order.subTotal).toBe(5079);
+                    expect(order.subTotalWithTax).toBe(5722);
                 });
             });
         });

+ 2 - 1
packages/core/src/service/services/product-variant.service.ts

@@ -16,6 +16,7 @@ import { RequestContext } from '../../api/common/request-context';
 import { RelationPaths } from '../../api/decorators/relations.decorator';
 import { RequestContextCacheService } from '../../cache/request-context-cache.service';
 import { ForbiddenError, UserInputError } from '../../common/error/errors';
+import { roundMoney } from '../../common/round-money';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { Translated } from '../../common/types/locale-types';
 import { idsAreEqual } from '../../common/utils';
@@ -649,7 +650,7 @@ export class ProductVariantService {
             await this.createOrUpdateProductVariantPrice(
                 ctx,
                 variant.id,
-                Math.round(price * priceFactor),
+                roundMoney(price * priceFactor),
                 input.channelId,
             );
             const assetIds = variant.assets?.map(a => a.assetId) || [];

+ 4 - 4
packages/dev-server/dev-config.ts

@@ -88,10 +88,10 @@ export const devConfig: VendureConfig = {
                 changeEmailAddressUrl: 'http://localhost:4201/change-email-address',
             },
         }),
-        AdminUiPlugin.init({
-            route: 'admin',
-            port: 5001,
-        }),
+        // AdminUiPlugin.init({
+        //     route: 'admin',
+        //     port: 5001,
+        // }),
     ],
 };
 

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 511 - 529
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 511 - 529
packages/payments-plugin/e2e/graphql/generated-admin-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 601 - 617
packages/payments-plugin/e2e/graphql/generated-shop-types.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 630 - 646
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio