@peabody-soft/mbs-model-ts 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/Address.js +8 -11
  2. package/dist/Attachment.js +7 -8
  3. package/dist/AttachmentBaseUrl.js +10 -10
  4. package/dist/AuthTokens.js +12 -7
  5. package/dist/Business.js +19 -19
  6. package/dist/BusinessIdentifier.js +2 -2
  7. package/dist/BusinessLink.js +16 -16
  8. package/dist/BusinessProfile.js +4 -4
  9. package/dist/CartEditor.d.ts +10 -5
  10. package/dist/CartEditor.js +258 -180
  11. package/dist/Catalog.js +31 -29
  12. package/dist/Constants.js +10 -10
  13. package/dist/ContainerNames.js +10 -11
  14. package/dist/Entity.js +11 -9
  15. package/dist/Enums.d.ts +1 -1
  16. package/dist/Enums.js +4 -3
  17. package/dist/Exceptions.js +2 -2
  18. package/dist/GeneralSettings.d.ts +4 -2
  19. package/dist/GeneralSettings.js +30 -18
  20. package/dist/HttpStatusCodes.js +10 -11
  21. package/dist/Ids.d.ts +2 -0
  22. package/dist/Ids.js +28 -24
  23. package/dist/Item.js +6 -7
  24. package/dist/LatLong.js +3 -3
  25. package/dist/LiveData.js +6 -6
  26. package/dist/Measures.js +2 -2
  27. package/dist/Message.js +6 -5
  28. package/dist/MockDatabase.js +2 -2
  29. package/dist/Model.d.ts +5 -0
  30. package/dist/Model.js +400 -254
  31. package/dist/Order.js +20 -17
  32. package/dist/Package.js +7 -8
  33. package/dist/PackageIdentifier.js +2 -2
  34. package/dist/PaymentMethodDetail.js +4 -4
  35. package/dist/Photo.js +6 -7
  36. package/dist/Price.js +9 -6
  37. package/dist/PriceList.js +2 -2
  38. package/dist/Product.d.ts +5 -3
  39. package/dist/Product.js +26 -19
  40. package/dist/ProductCategory.js +3 -4
  41. package/dist/Profile.d.ts +1 -0
  42. package/dist/Profile.js +94 -8
  43. package/dist/ProtoMap.d.ts +18 -8
  44. package/dist/ProtoMap.js +451 -331
  45. package/dist/Routes.js +3 -3
  46. package/dist/ServiceExceptions.js +11 -12
  47. package/dist/ServiceProxy.js +19 -23
  48. package/dist/ShoppingList.d.ts +30 -1
  49. package/dist/ShoppingList.js +339 -47
  50. package/dist/SyncCache.d.ts +12 -0
  51. package/dist/SyncCache.js +136 -56
  52. package/dist/SyncKeys.js +8 -8
  53. package/dist/SyncState.js +5 -5
  54. package/dist/Tax.d.ts +12 -4
  55. package/dist/Tax.js +37 -13
  56. package/dist/TaxHelper.d.ts +12 -0
  57. package/dist/TaxHelper.js +85 -0
  58. package/dist/TaxType.d.ts +7 -0
  59. package/dist/TaxType.js +31 -0
  60. package/dist/UserProfile.js +4 -4
  61. package/dist/WorkingDays.js +15 -15
  62. package/dist/generated/AttachmentType.d.ts +19 -1
  63. package/dist/generated/AttachmentType.js +18 -0
  64. package/dist/generated/AuthTokens.d.ts +3 -1
  65. package/dist/generated/BusinessLink.d.ts +4 -1
  66. package/dist/generated/BusinessSyncInRequest.d.ts +11 -3
  67. package/dist/generated/BusinessSyncInResponse.d.ts +5 -4
  68. package/dist/generated/BusinessSyncOutRequest.d.ts +37 -10
  69. package/dist/generated/BusinessSyncOutRequest.js +42 -29
  70. package/dist/generated/BusinessSyncOutResponse.d.ts +14 -1
  71. package/dist/generated/Configuration.d.ts +2 -1
  72. package/dist/generated/CreateUserRequest.d.ts +8 -2
  73. package/dist/generated/Entity.d.ts +7 -3
  74. package/dist/generated/GeneralSettings.d.ts +14 -4
  75. package/dist/generated/GeneralSettings.js +9 -1
  76. package/dist/generated/Message.d.ts +4 -2
  77. package/dist/generated/Product.d.ts +10 -1
  78. package/dist/generated/Product.js +9 -1
  79. package/dist/generated/ProfileSyncOutRequest.d.ts +17 -1
  80. package/dist/generated/ProfileSyncOutResponse.d.ts +8 -3
  81. package/dist/generated/SalesOrderPayload.d.ts +7 -2
  82. package/dist/generated/ShoppingList.d.ts +6 -3
  83. package/dist/generated/ShoppingListItem.d.ts +21 -6
  84. package/dist/generated/Tax.d.ts +4 -4
  85. package/dist/generated/Tax.js +7 -8
  86. package/dist/generated/WorkingHoursRange.d.ts +2 -2
  87. package/dist/index.d.ts +2 -2
  88. package/dist/index.js +11 -10
  89. package/package.json +55 -55
  90. package/dist/generated/TaxType.d.ts +0 -25
  91. package/dist/generated/TaxType.js +0 -30
@@ -1,53 +1,58 @@
1
1
  import { Constants as proto_Constants } from "./generated/Constants";
2
2
  import { FulfillmentMode as proto_FulfillmentMode } from "./generated/FulfillmentMode";
3
3
  import { ReservedPackage as proto_ReservedPackage } from "./generated/ReservedPackage";
4
- import { MERABILLS_ONLINE_PRICE_LIST_ID } from "./Constants";
4
+ import { MERABILLS_ONLINE_PRICE_LIST_ID, amountFromScaledValue, amountToScaledValue, } from "./Constants";
5
5
  import { InvalidArgumentException } from "./Exceptions";
6
6
  import Ids from "./Ids";
7
- import ShoppingList, { ShoppingListItem } from "./ShoppingList";
8
- // Builds the cart a customer is filling, in the terms a UI has to hand: packages rather than
9
- // package ids, quantities rather than quantities multiplied by Constants.QUANTITY_FACTOR, and
10
- // prices taken off the catalog rather than worked out and passed in. Get one from
11
- // Business.createCartEditor, edit it, then hand build() to Model.updateCartLocalOnly.
7
+ import ShoppingList, { ShoppingListItem, validatePricing } from "./ShoppingList";
8
+ import TaxHelper from "./TaxHelper";
9
+ // Builds the cart a customer is filling, in the terms a UI has to hand: packages, quantities as
10
+ // the customer means them (not multiplied by Constants.QUANTITY_FACTOR), prices from the catalog.
11
+ // Get one from Business.createCartEditor, edit it, then hand build() to Model.updateCartLocalOnly.
12
12
  //
13
- // Mutable, and deliberately the only mutable thing in this object model: it is a scratch copy of
14
- // the cart, not the cart. Nothing here writes anything - editing an editor changes no Business, no
15
- // store and nothing on the server until a built list is pushed. It is also a snapshot: it reads the
16
- // catalog, settings and cart once, at construction, so a sync arriving mid-edit does not move
17
- // prices under the customer. Make a fresh one when the Business republishes.
13
+ // Mutable, and the only mutable thing in this object model. It is a scratch copy: editing it
14
+ // changes no Business, store or server until a built list is handed to Model. It is also a
15
+ // snapshot, read once at construction, so a sync mid-edit does not move prices under the customer.
16
+ // Make a fresh one when the Business republishes.
17
+ //
18
+ // EVERY PRICE HERE INCLUDES TAXES: a package's includes its product's taxes, the delivery charge
19
+ // the taxes the settings name for it, so a customer pays what the catalog shows. A shopping list
20
+ // item stores a price WITHOUT taxes, so building one takes them out, as Java
21
+ // ShoppingListItem.setDetailsFromTaxInclusivePrices does (to the unit, see TaxHelper). Taxes are
22
+ // shown as what a total includes (taxes), never added to it.
18
23
  class CartEditor {
19
- // Business.createCartEditor is how a UI gets one of these; this takes the three things it needs
20
- // rather than the Business itself, so a caller can build a cart against a catalog and settings
21
- // that are not the ones currently published.
24
+ // Business.createCartEditor is how a UI gets one. It takes the catalog, settings and cart, not
25
+ // the Business, so a caller can build a cart against ones that are not currently published.
22
26
  //
23
- // Every line is priced off the catalog now, not off what the cart was holding: a cart lives
24
- // until it is ordered, the merchant may have repriced in between, and what an order freezes is
25
- // the price at the moment it is placed. Lines the catalog can no longer price - a package
26
- // withdrawn, disabled, or left without a price - are dropped, and left in unavailableItems for a
27
- // UI to tell the customer about. The charge lines of the list it started from (see
28
- // ShoppingListItem.isReservedPackage) are dropped too, being this class's to work out rather
29
- // than a customer's to carry: build() puts back whatever the settings now say.
27
+ // Every line is priced off the catalog, not off what the cart held: the merchant may have
28
+ // repriced since, and an order freezes the price when it is placed. A line goes to
29
+ // unavailableItems, for a UI to tell the customer about, when the catalog can no longer price it
30
+ // (package withdrawn, disabled or without a price) or setQuantity would refuse it (a cart that
31
+ // was read is not known to be one this class could have made): a zero or invalid quantity, a
32
+ // fraction of a package not sold loose, more than the wire or the protocol carries at today's
33
+ // price, or a line past the most a cart holds (the first lines are kept, in pick order).
34
+ // The charge lines of the starting list (see ShoppingListItem.isReservedPackage) are dropped, not
35
+ // listed, since build() puts back what the settings now say.
30
36
  constructor(_catalog, _settings, cart) {
31
37
  this._catalog = _catalog;
32
38
  this._settings = _settings;
33
- // Insertion ordered, which is what keeps a cart in the order the customer filled it and what
34
- // gives each line its shoppingListOrder
39
+ // Insertion ordered: keeps the cart in the order the customer filled it and gives each line its
40
+ // shoppingListOrder
35
41
  this._quantities = new Map();
36
42
  this._inventoryLocationId = cart?.inventoryLocationId ?? 0;
43
+ // goodsItems is in the order the customer picked. existingItems is not, the server sending it
44
+ // by package id, so a cart read back would reorder itself
37
45
  const unavailableItems = [];
38
- for (const item of cart?.existingItems ?? []) {
46
+ for (const item of cart?.goodsItems ?? []) {
39
47
  if (item.isReservedPackage)
40
48
  continue;
41
- const buyable = this.getBuyablePackage(item.packageId);
42
- if (buyable === null)
49
+ if (!this.tryKeep(item))
43
50
  unavailableItems.push(item);
44
- else
45
- this._quantities.set(buyable.id, item.quantity);
46
51
  }
47
52
  this._unavailableItems = Object.freeze(unavailableItems);
48
53
  }
49
- // What the customer has picked, in the order it was picked in, each line priced as it would be
50
- // bought right now. Rebuilt on every read, so a line held across an edit is stale.
54
+ // What the customer has picked, in pick order, each line priced as it would be bought now.
55
+ // Rebuilt on every read, so a line held across an edit is stale.
51
56
  get lines() {
52
57
  const lines = [];
53
58
  for (const [packageId, quantity] of this._quantities) {
@@ -58,15 +63,15 @@ class CartEditor {
58
63
  }
59
64
  return Object.freeze(lines);
60
65
  }
61
- // The lines of the cart this editor started from that its catalog can no longer price - see the
62
- // note on the constructor. Empty for a cart built from nothing, and never added to by an edit.
66
+ // The lines of the starting cart that the catalog can no longer price (see the constructor).
67
+ // Empty for a cart built from nothing, and no edit adds to it.
63
68
  get unavailableItems() {
64
69
  return this._unavailableItems;
65
70
  }
66
71
  get isEmpty() {
67
72
  return this._quantities.size === 0;
68
73
  }
69
- // How many lines the cart has, which is what a cart badge counts - not how many units are in it
74
+ // The number of lines, which is what a cart badge counts
70
75
  get lineCount() {
71
76
  return this._quantities.size;
72
77
  }
@@ -81,33 +86,24 @@ class CartEditor {
81
86
  getQuantity(item) {
82
87
  return this._quantities.get(item.id) ?? 0;
83
88
  }
84
- // Sets how much of one package the cart holds; zero takes it out. A package a customer cannot
85
- // buy - one disabled, or priced in no list this storefront sells from - is refused, as is a
86
- // fractional quantity of a package that is not sold loose, and a quantity past what the wire can
87
- // carry. Returns this, so edits chain.
89
+ // Sets how much of one package the cart holds; zero takes it out. Refuses a package a customer
90
+ // cannot buy (disabled, or priced in no list this storefront sells from), a fractional quantity
91
+ // of a package that is not sold loose, and a quantity past what the wire can carry. Sold loose is
92
+ // read from the catalog's package, since the Package passed in may predate a catalog change.
93
+ // Returns this, so edits chain.
88
94
  setQuantity(item, quantity) {
89
- if (!Number.isFinite(quantity) || quantity < 0)
90
- throw new InvalidArgumentException(`${quantity} is not a quantity`);
91
- if (quantity === 0) {
95
+ const wireQuantity = CartEditor.toWireQuantity(quantity);
96
+ // Less than half a thousandth rounds to zero on the wire, a line that costs nothing, so it
97
+ // takes the line out as zero does
98
+ if (wireQuantity === 0) {
92
99
  this._quantities.delete(item.id);
93
100
  return this;
94
101
  }
95
- if (this.getBuyablePackage(item.id) === null)
96
- throw new InvalidArgumentException(`Package ${Ids.toStorageHexString(item.id)} cannot be bought from this catalog`);
97
- if (!item.isSoldLoose && !Number.isInteger(quantity))
98
- throw new InvalidArgumentException(`${item.name} is not sold loose, so it cannot be bought ${quantity} at a time`);
99
- // A quantity travels as an int32 multiplied by Constants.QUANTITY_FACTOR, so this is the most
100
- // of one package a cart can hold - and it is refused here rather than silently wrapping there.
101
- if (quantity > CartEditor.MAX_QUANTITY)
102
- throw new InvalidArgumentException(`${quantity} is more than the ${CartEditor.MAX_QUANTITY} the protocol can carry`);
103
- if (!this._quantities.has(item.id) &&
104
- this._quantities.size >= proto_Constants.SHOPPING_LIST_MAX_ITEMS)
105
- throw new InvalidArgumentException(`A cart holds at most ${proto_Constants.SHOPPING_LIST_MAX_ITEMS} lines`);
106
- this._quantities.set(item.id, quantity);
102
+ this._quantities.set(item.id, this.checkQuantity(item.id, quantity, wireQuantity));
107
103
  return this;
108
104
  }
109
- // Adds to what the cart already holds of this package, which is what a quantity stepper does.
110
- // A negative amount takes away, and never below nothing.
105
+ // Adds to what the cart holds of this package, as a quantity stepper does. A negative amount
106
+ // takes away, down to zero at the lowest.
111
107
  add(item, quantity = 1) {
112
108
  return this.setQuantity(item, Math.max(0, this.getQuantity(item) + quantity));
113
109
  }
@@ -118,207 +114,289 @@ class CartEditor {
118
114
  this._quantities.clear();
119
115
  return this;
120
116
  }
121
- // What the packages cost before tax, offers already taken off
117
+ // What the packages come to, taxes included and offers taken off: the sum of the lines' final
118
+ // prices, which a customer sees as the price of the cart. Delivery is not in it.
122
119
  get itemsTotal() {
123
- let result = 0;
124
- for (const line of this.lines)
125
- result += line.amount;
126
- return result;
120
+ return ShoppingList.sumOfFinalPrices(this.lines.map((line) => line.item));
127
121
  }
128
- // What the offers took off, which is what a bill shows as savings. Zero when nothing is on offer.
122
+ // What the offers took off the packages, shown on a bill as savings: the total at regular prices
123
+ // less the total. Zero when nothing is on offer. Taxes are included on both sides.
129
124
  get savings() {
130
- let result = 0;
131
- for (const line of this.lines)
132
- result += line.savings;
133
- return result;
125
+ return this.build().savings;
134
126
  }
127
+ // The taxes already inside itemsTotal, all types together. See taxes for the split by type.
135
128
  get itemsTax() {
136
- let result = 0;
137
- for (const line of this.lines)
138
- result += line.totalTax;
139
- return result;
129
+ return this.build().computedTotalTax;
140
130
  }
141
- // The packages with their tax, and what the free delivery threshold is measured against - the
142
- // same total Java ShoppingCartViewModel.calculateDeliveryCharge compares, less its delivery line
143
- get itemsFinalTotal() {
144
- let result = 0;
145
- for (const line of this.lines)
146
- result += line.finalPrice;
147
- return result;
131
+ // The taxes the cart's total includes, one entry per TYPE of tax. A bill shows a line for each
132
+ // that is not zero. withDeliveryCharge is as for total: the delivery charge's taxes count only
133
+ // when it is true.
134
+ taxes(withDeliveryCharge = false) {
135
+ return this.build(withDeliveryCharge).computedTaxes;
148
136
  }
149
- // True when this business delivers at all. False means no delivery charge exists to add, whatever
150
- // build() is asked for, and a UI has no fulfillment choice to offer either.
137
+ // True when this business delivers. When false there is no delivery charge for build() to add,
138
+ // and no fulfillment choice for a UI to offer.
151
139
  get isDeliveryOffered() {
152
140
  return this._settings.fulfillmentModes.includes(proto_FulfillmentMode.DELIVERY);
153
141
  }
154
- // What delivering this cart would cost before tax: the business's flat charge, or zero where it
155
- // has been waived by the cart reaching freeDeliveryTotal, or where the business delivers free
156
- // anyway. Always zero when the business does not deliver.
142
+ // What delivering this cart costs, taxes included: the business's flat charge, or zero when the
143
+ // cart has reached freeDeliveryTotal or the business delivers free. Zero when the business does
144
+ // not deliver.
157
145
  get deliveryCharge() {
158
- return this.getDeliveryCharge(this.itemsFinalTotal);
146
+ return this.buildDeliveryChargeItem(this.getDeliveryCharge(this.itemsTotal), 0)?.finalPrice ?? 0;
159
147
  }
160
- // What delivery costs before any waiver - the price a bill strikes through when it reads FREE.
161
- // Zero where the business delivers free anyway, in which case there is nothing to strike through.
148
+ // What delivery costs before any waiver, which a bill strikes through when it reads FREE. Zero
149
+ // where the business delivers free, so there is nothing to strike through.
162
150
  get regularDeliveryCharge() {
163
- if (!this.isDeliveryOffered)
164
- return 0;
165
- // Java clamps a negative charge to zero rather than treating it as a refund, and so does this
166
- return Math.max(0, this._settings.deliveryCharge);
151
+ return this.buildDeliveryChargeItem(this.regularCharge, 0)?.finalPrice ?? 0;
167
152
  }
168
- // True when delivering this cart costs nothing, whether because the business never charges or
169
- // because this cart has reached the threshold
153
+ // True when delivering this cart costs nothing, because the business never charges or the cart
154
+ // has reached the threshold
170
155
  get isDeliveryFree() {
171
156
  return this.isDeliveryOffered && this.deliveryCharge === 0;
172
157
  }
173
- // How much more the customer would have to add for delivery to stop being charged, which is the
174
- // "add X more for free delivery" note. Zero once it is already free, and null when the business
175
- // has set no threshold, so there is no amount that would ever make it free.
158
+ // How much more the customer must add for free delivery, for the "add X more for free delivery"
159
+ // note. Zero when delivery is already free. Null when the business does not deliver or has set no
160
+ // threshold, so no amount would make it free.
176
161
  get amountToFreeDelivery() {
177
162
  if (!this.isDeliveryOffered)
178
163
  return null;
179
- if (this.regularDeliveryCharge === 0)
164
+ if (this.regularCharge === 0)
180
165
  return 0; // Already free, and free at any total
181
166
  const freeDeliveryTotal = this._settings.freeDeliveryTotal;
182
167
  if (freeDeliveryTotal <= 0)
183
168
  return null; // No threshold: adding more changes nothing
184
- return Math.max(0, freeDeliveryTotal - this.itemsFinalTotal);
169
+ const remaining = amountToScaledValue(freeDeliveryTotal) - amountToScaledValue(this.itemsTotal);
170
+ return remaining > 0n ? amountFromScaledValue(remaining) : 0;
185
171
  }
186
- // Tax on the delivery charge, at the rates GeneralSettings.deliveryChargeTaxes carries - which
187
- // are the business's own and have nothing to do with a product's. Zero when delivery is free,
188
- // there being nothing to tax.
172
+ // The tax inside deliveryCharge, from GeneralSettings.deliveryChargeTaxes. Those are the
173
+ // business's own, unrelated to a product's. Included in the charge, not added to it. Zero when
174
+ // delivery is free.
189
175
  get deliveryTax() {
190
- return CartEditor.getTax(this.deliveryCharge, this._settings.deliveryChargeTaxes);
176
+ return this.buildDeliveryChargeItem(this.getDeliveryCharge(this.itemsTotal), 0)?.totalTax ?? 0;
191
177
  }
192
- // What the cart comes to. Delivery is left out unless asked for, the way a cart page leaves it
193
- // out and a checkout page puts it in - the same choice build() takes.
178
+ // What the cart comes to, taxes included. Delivery is left out unless asked for: a cart page
179
+ // leaves it out and a checkout page puts it in, as in build(). It is the total of the list
180
+ // build() answers, and so of an order placed from it.
194
181
  total(withDeliveryCharge = false) {
195
- const total = this.itemsFinalTotal;
196
- if (!withDeliveryCharge || this.isEmpty)
197
- return total;
198
- // Worked out from the total already in hand, rather than through the two getters above, each
199
- // of which would add the lines up again
200
- const charge = this.getDeliveryCharge(total);
201
- return total + charge + CartEditor.getTax(charge, this._settings.deliveryChargeTaxes);
202
- }
203
- // The cart as a list to write: what Model.updateCartLocalOnly and Model.updateCart take, and
204
- // what a later Model.createOrder turns into an order.
205
- //
206
- // withDeliveryCharge decides whether the delivery charge rides along as a line of its own,
207
- // carrying ReservedPackage.DELIVERY_CHARGE_PACKAGE and flagged by
208
- // ShoppingList.autoAddDeliveryCharge. A cart page has no fulfillment chosen yet and so leaves it
209
- // out, which is the default; a checkout page that has settled on delivery asks for it. The line
210
- // is added even when the charge has been waived - at zero, with the regular charge as its
211
- // priceDiscount - so a bill can show FREE with the original struck through, which is what Java
212
- // ShoppingCartViewModel.setDeliveryCharge writes too.
182
+ return this.build(withDeliveryCharge).totalAmount;
183
+ }
184
+ // The cart as a list, to write (Model.updateCartLocalOnly, Model.updateCart, Model.createOrder)
185
+ // and to SHOW: every price, total and tax a cart or checkout page displays can be read off it.
186
+ // Each line stores its price without taxes and is flagged original, as Java ShoppingList.addItem
187
+ // does before a list is final. The list comes back not final: Model.createOrder makes it so.
213
188
  //
214
- // Nothing is added to an empty cart: a list holding a delivery charge and no goods is not an
215
- // order, and Java removes exactly that line when it is all that is left.
216
- // The list comes back not final. Making it final is placing an order, which is
217
- // Model.createOrder's to do.
189
+ // withDeliveryCharge adds the delivery charge as a line (ReservedPackage.DELIVERY_CHARGE_PACKAGE)
190
+ // and sets ShoppingList.autoAddDeliveryCharge. A cart page leaves it out (the default), a checkout
191
+ // page that has settled on delivery asks for it. The line is added even when the charge is
192
+ // waived: at zero, with the regular charge (less its taxes) as its priceDiscount, so a bill can
193
+ // show FREE with the original struck through (Java ShoppingCartViewModel.setDeliveryCharge).
194
+ // An empty cart gets no line: a list holding only a delivery charge is not an order, and Java
195
+ // removes that line when it is all that is left.
218
196
  build(withDeliveryCharge = false) {
219
197
  const items = [];
220
198
  for (const line of this.lines)
221
199
  items.push(line.toShoppingListItem(items.length));
222
- const isDeliveryCharged = withDeliveryCharge && items.length > 0 && this.isDeliveryOffered;
223
- if (isDeliveryCharged)
224
- items.push(this.buildDeliveryChargeItem(items.length));
225
- // The total is written off the lines being sent, the way ShoppingList.proto defines it: the
226
- // sum of their final prices, tax included. There is no list-wide discount - an offer comes off
227
- // a line, and a storefront gives no other.
228
- let totalAmountBeforeDiscount = 0;
229
- for (const item of items)
230
- totalAmountBeforeDiscount += item.finalPrice;
231
- return new ShoppingList(false, isDeliveryCharged, false, this._inventoryLocationId, this._catalog.priceList?.id ?? MERABILLS_ONLINE_PRICE_LIST_ID, totalAmountBeforeDiscount, 0, items.length, 0, Object.freeze(items), Object.freeze([]));
232
- }
233
- // The delivery charge as a line: one of it, at whatever it costs after any waiver, with the
234
- // waived amount as its price discount - the same shape as a package bought on offer, which is
235
- // what lets a bill render it with no special case beyond its label.
236
- buildDeliveryChargeItem(shoppingListOrder) {
237
- const charge = this.deliveryCharge;
238
- return new ShoppingListItem(Ids.buildId(proto_Constants.PACKAGE_ENTITY_TYPE_ID, BigInt(proto_ReservedPackage.DELIVERY_CHARGE_PACKAGE)), 1, charge, shoppingListOrder, false, false, false, this.regularDeliveryCharge - charge, 0, this._settings.deliveryChargeTaxes, 0, null);
239
- }
240
- // The charge against a cart that comes to this much, which is the whole of the policy. Takes
241
- // the total rather than reading it, every caller here having worked it out already.
242
- getDeliveryCharge(itemsFinalTotal) {
243
- const charge = this.regularDeliveryCharge;
200
+ const delivery = withDeliveryCharge && items.length > 0
201
+ ? this.buildDeliveryChargeItem(this.getDeliveryCharge(ShoppingList.sumOfFinalPrices(items)), items.length)
202
+ : null;
203
+ if (delivery !== null)
204
+ items.push(delivery);
205
+ // The total is the sum of the final prices of the lines sent, tax included, as ShoppingList.proto
206
+ // defines it. There is no list-wide discount: an offer comes off a line, and a storefront gives
207
+ // no other.
208
+ return new ShoppingList(false, delivery !== null, false, this._inventoryLocationId, this._catalog.priceList?.id ?? MERABILLS_ONLINE_PRICE_LIST_ID, ShoppingList.sumOfFinalPrices(items), 0, items.length, 0, Object.freeze(items), Object.freeze([]));
209
+ }
210
+ // The delivery charge as a line: one unit at what it costs now (charge: the regular charge, or
211
+ // zero once waived), with the waived amount as its price discount. That is the shape of a package
212
+ // bought on offer, so a bill renders it with no special case beyond its label. Null when the
213
+ // business does not deliver. The settings' charge includes the delivery charge taxes, so they
214
+ // come out of it like those of any other price.
215
+ buildDeliveryChargeItem(charge, shoppingListOrder) {
216
+ if (!this.isDeliveryOffered)
217
+ return null;
218
+ return buildItem(Ids.buildId(proto_Constants.PACKAGE_ENTITY_TYPE_ID, BigInt(proto_ReservedPackage.DELIVERY_CHARGE_PACKAGE)), 1, charge, this.regularCharge, this._settings.deliveryChargeTaxes, shoppingListOrder);
219
+ }
220
+ // The charge before any waiver. Java clamps a negative charge to zero (no refund), and so does
221
+ // this. Zero when the business does not deliver.
222
+ get regularCharge() {
223
+ if (!this.isDeliveryOffered)
224
+ return 0;
225
+ return Math.max(0, this._settings.deliveryCharge);
226
+ }
227
+ // The charge for a cart whose goods come to itemsTotal: the regular charge, or nothing once the
228
+ // goods reach the threshold. Java ShoppingCartViewModel.calculateDeliveryCharge compares the same
229
+ // total, taxes included and the delivery line left out.
230
+ getDeliveryCharge(itemsTotal) {
231
+ const charge = this.regularCharge;
244
232
  if (charge === 0)
245
233
  return 0; // Free for everyone, or not delivered at all
246
234
  const freeDeliveryTotal = this._settings.freeDeliveryTotal;
247
235
  if (freeDeliveryTotal <= 0)
248
- return charge; // Never free, whatever the cart totals
249
- return itemsFinalTotal >= freeDeliveryTotal ? 0 : charge;
250
- }
251
- static getTax(amount, taxes) {
252
- let result = 0;
253
- for (const tax of taxes)
254
- result += (amount * tax.rate) / 100;
255
- return result;
236
+ return charge; // Never free, at any total
237
+ return itemsTotal >= freeDeliveryTotal ? 0 : charge;
256
238
  }
257
239
  // The package this id names, and null unless a customer could actually buy it right now
258
240
  getBuyablePackage(packageId) {
259
241
  const item = this._catalog.getPackage(packageId);
260
242
  return item !== null && item.isBuyable ? item : null;
261
243
  }
244
+ // Keeps a line of a cart that was read, as setQuantity would, and says whether it could. A line
245
+ // that cannot be kept goes to unavailableItems, as it is not the caller's mistake.
246
+ tryKeep(item) {
247
+ try {
248
+ const wireQuantity = CartEditor.toWireQuantity(item.quantity);
249
+ if (wireQuantity === 0)
250
+ return false; // A line of nothing is no line
251
+ this._quantities.set(item.packageId, this.checkQuantity(item.packageId, item.quantity, wireQuantity));
252
+ return true;
253
+ }
254
+ catch (error) {
255
+ if (error instanceof InvalidArgumentException)
256
+ return false;
257
+ throw error;
258
+ }
259
+ }
260
+ // A quantity travels as an int32 multiplied by Constants.QUANTITY_FACTOR (Java rounds to it with
261
+ // NumberHelper.floatToInt), so the least is a thousandth. This converts a quantity to that. What
262
+ // is kept is the quantity the wire holds, so getQuantity never reports one the order will not
263
+ // have.
264
+ static toWireQuantity(quantity) {
265
+ if (!Number.isFinite(quantity) || quantity < 0)
266
+ throw new InvalidArgumentException(`${quantity} is not a quantity`);
267
+ return Math.round(quantity * proto_Constants.QUANTITY_FACTOR);
268
+ }
269
+ // The quantity to keep for this much of a package, as the wire holds it, or an
270
+ // InvalidArgumentException saying why the cart cannot have it: the catalog does not sell the
271
+ // package, it is not sold loose and the quantity is a fraction, the quantity or what it comes to
272
+ // is more than the protocol carries, or the cart is full. All of it is read off the catalog's
273
+ // package, which the order is priced from.
274
+ checkQuantity(packageId, quantity, wireQuantity) {
275
+ const buyable = this.getBuyablePackage(packageId);
276
+ if (buyable === null)
277
+ throw new InvalidArgumentException(`Package ${Ids.toStorageHexString(packageId)} cannot be bought from this catalog`);
278
+ if (!buyable.isSoldLoose && !Number.isInteger(quantity))
279
+ throw new InvalidArgumentException(`${buyable.name} is not sold loose, so it cannot be bought ${quantity} at a time`);
280
+ // The most of one package a cart can hold. Refused here so it does not wrap silently on the wire
281
+ if (wireQuantity > CartEditor.MAX_WIRE_QUANTITY)
282
+ throw new InvalidArgumentException(`${quantity} is more than the ${CartEditor.MAX_WIRE_QUANTITY / proto_Constants.QUANTITY_FACTOR} the protocol can carry`);
283
+ // A list holds at most Constants.SHOPPING_LIST_MAX_ITEMS items, and the delivery charge is one
284
+ // (Java ShoppingList.addItem counts it like any other, and a server refuses a list one line
285
+ // over). Where the business delivers, one place is kept for it: a cart filled to the limit
286
+ // could not be ordered for delivery.
287
+ const maxLines = proto_Constants.SHOPPING_LIST_MAX_ITEMS - (this.isDeliveryOffered ? 1 : 0);
288
+ if (!this._quantities.has(packageId) && this._quantities.size >= maxLines)
289
+ throw new InvalidArgumentException(`A cart holds at most ${maxLines} lines`);
290
+ // Java refuses an amount past Transaction.MAX_AMOUNT (validateAmount) in the item that comes to
291
+ // it, and so does pricing an item here. It is priced before the line is kept, so the failure
292
+ // does not wait until a list is written.
293
+ const stored = wireQuantity / proto_Constants.QUANTITY_FACTOR;
294
+ try {
295
+ validatePricing(new CartLine(buyable, this._catalog.getProduct(buyable.productId), stored).item);
296
+ }
297
+ catch (error) {
298
+ if (error instanceof InvalidArgumentException)
299
+ throw new InvalidArgumentException(`${quantity} of ${buyable.name} comes to more than the protocol can carry (${error.message})`);
300
+ throw error;
301
+ }
302
+ return stored;
303
+ }
262
304
  }
263
- // What an int32 quantity multiplied by Constants.QUANTITY_FACTOR reaches
264
- CartEditor.MAX_QUANTITY = Math.floor(0x7fffffff / proto_Constants.QUANTITY_FACTOR);
305
+ // The most an int32 holds, which bounds a quantity multiplied by Constants.QUANTITY_FACTOR
306
+ CartEditor.MAX_WIRE_QUANTITY = 0x7fffffff;
265
307
  export default CartEditor;
266
- // One line of a cart being edited: the package, the product it is a variant of, and how much of it
267
- // - with the prices the catalog gives it now. A read-only view over what CartEditor holds, rebuilt
268
- // on every read of CartEditor.lines rather than kept.
308
+ // One line of a cart being edited: the package, the product it is a variant of, and how much of it,
309
+ // with the prices the catalog gives it now, taxes included. A read-only view over what CartEditor
310
+ // holds, rebuilt on every read of CartEditor.lines.
311
+ //
312
+ // Everything is read off item, the line as a shopping list would store it, so the cart page shows
313
+ // what the order will and no price is worked out twice.
269
314
  export class CartLine {
270
315
  constructor(_package, _product, _quantity) {
271
316
  this._package = _package;
272
317
  this._product = _product;
273
318
  this._quantity = _quantity;
319
+ this._item = null;
274
320
  }
275
321
  get package() {
276
322
  return this._package;
277
323
  }
278
- // Null only for a package whose product is not in the catalog, which a buyable package's never is
324
+ // Null when the package's product is not in the catalog, which a buyable package never has
279
325
  get product() {
280
326
  return this._product;
281
327
  }
282
328
  get quantity() {
283
329
  return this._quantity;
284
330
  }
285
- // What one unit costs: the offer price where the package is on offer, else the regular price.
286
- // A buyable package always has a price, so this is never guessed.
331
+ // This line as a shopping list holds it: priced without taxes, with the taxes beside it. Show
332
+ // this to show the line as an order does. Its place in the list means nothing here.
333
+ get item() {
334
+ this._item ?? (this._item = this.toShoppingListItem(0));
335
+ return this._item;
336
+ }
337
+ // The taxes of the package's product, which its price includes. A rate not greater than zero is
338
+ // no tax, as in Java, and is left out.
339
+ get taxes() {
340
+ return includedTaxes(this._product?.taxes ?? []);
341
+ }
342
+ // What one unit costs, taxes included: the offer price where the package is on offer, else the
343
+ // regular price. This is the catalog's price to within tax rounding (a thousandth or two, which
344
+ // no one is shown). A buyable package always has a price, so nothing is guessed.
287
345
  get unitPrice() {
288
- return this._package.price?.finalPrice ?? 0;
346
+ return this.item.unitPriceWithTaxes;
289
347
  }
348
+ // The regular price of a unit, taxes included. Struck through when it is on offer
290
349
  get regularUnitPrice() {
291
- return this._package.price?.regularPrice ?? 0;
350
+ return this.item.regularUnitPriceWithTaxes;
292
351
  }
293
352
  get isOnOffer() {
294
353
  return this._package.price?.isOnOffer ?? false;
295
354
  }
296
- // The taxes of the product this package belongs to, which are what the line is taxed at
297
- get taxes() {
298
- return this._product?.taxes ?? EMPTY_TAXES;
299
- }
300
- // What this line costs before tax
355
+ // What this line costs before its taxes: its tax base. A bill that shows prices with taxes shows
356
+ // finalPrice.
301
357
  get amount() {
302
- return this.unitPrice * this._quantity;
358
+ return this.item.amount;
303
359
  }
304
- // What the offer takes off this line
360
+ // What the offer takes off this line, taxes included
305
361
  get savings() {
306
- return (this.regularUnitPrice - this.unitPrice) * this._quantity;
362
+ const saved = amountToScaledValue(this.item.finalPriceWithoutDiscounts) -
363
+ amountToScaledValue(this.item.finalPrice);
364
+ return saved > 0n ? amountFromScaledValue(saved) : 0;
307
365
  }
366
+ // The taxes inside finalPrice
308
367
  get totalTax() {
309
- let result = 0;
310
- for (const tax of this.taxes)
311
- result += (this.amount * tax.rate) / 100;
312
- return result;
368
+ return this.item.totalTax;
313
369
  }
314
- // What this line adds to the cart: what it costs plus the tax on it
370
+ // What this line adds to the cart and a customer pays for it: the unit price times the quantity,
371
+ // with its taxes already in it
315
372
  get finalPrice() {
316
- return this.amount + this.totalTax;
373
+ return this.item.finalPrice;
317
374
  }
318
- // This line as the protocol carries it. Only CartEditor.build calls this, which is what assigns
319
- // the order.
375
+ // This line as the protocol carries it. CartEditor.build calls it and assigns the order. Taxes
376
+ // come out of the price on the way (see buildItem).
320
377
  toShoppingListItem(shoppingListOrder) {
321
- return new ShoppingListItem(this._package.id, this._quantity, this.unitPrice, shoppingListOrder, false, false, false, this.regularUnitPrice - this.unitPrice, 0, this.taxes, 0, null);
378
+ const price = this._package.price;
379
+ return buildItem(this._package.id, this._quantity, price?.finalPrice ?? 0, price?.regularPrice ?? 0, this._product?.taxes ?? [], shoppingListOrder);
322
380
  }
323
381
  }
324
- const EMPTY_TAXES = Object.freeze([]);
382
+ // The taxes a price includes, as an item carries them: only those with a rate, as Java does with a
383
+ // product's taxes (TaxHelper.getTaxCodesAndRates drops the rest).
384
+ function includedTaxes(taxes) {
385
+ return Object.freeze(taxes.filter((tax) => Number.isFinite(tax.rate) && tax.rate > 0));
386
+ }
387
+ // Where a price with taxes in it becomes a shopping list item, and so where CartEditor takes a tax
388
+ // out of a price, as Java ShoppingListItem.setDetailsFromTaxInclusivePrices does. The offer price
389
+ // and the regular price each have their taxes removed, and the price discount is the difference of
390
+ // those two, which is not the difference of the prices with taxes. The rest is as Java's
391
+ // ProductSelectionCard leaves a new item: no discount of its own, no description (that is for
392
+ // lines with no package), no stock keeping or expiry (a storefront is not told). It is flagged
393
+ // original, as in build.
394
+ // A price is a whole number of thousandths and each tax is rounded, so some prices cannot be
395
+ // reached. The item then has the nearest price (TaxHelper.removeTaxes).
396
+ function buildItem(packageId, quantity, offerPriceWithTaxes, regularPriceWithTaxes, taxes, shoppingListOrder) {
397
+ const included = includedTaxes(taxes);
398
+ const rates = TaxHelper.scaledRatesOf(included);
399
+ const offerPrice = TaxHelper.removeTaxes(amountToScaledValue(offerPriceWithTaxes), rates);
400
+ const regularPrice = TaxHelper.removeTaxes(amountToScaledValue(regularPriceWithTaxes), rates);
401
+ return new ShoppingListItem(packageId, quantity, amountFromScaledValue(offerPrice), shoppingListOrder, true, false, false, amountFromScaledValue(regularPrice > offerPrice ? regularPrice - offerPrice : 0n), 0, included, 0, null);
402
+ }