@peabody-soft/mbs-model-ts 1.4.0 → 1.5.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 (61) hide show
  1. package/dist/CartEditor.d.ts +10 -5
  2. package/dist/CartEditor.js +232 -125
  3. package/dist/Catalog.js +26 -18
  4. package/dist/Enums.d.ts +1 -1
  5. package/dist/Enums.js +2 -1
  6. package/dist/GeneralSettings.d.ts +4 -2
  7. package/dist/GeneralSettings.js +18 -7
  8. package/dist/Ids.d.ts +2 -0
  9. package/dist/Ids.js +10 -0
  10. package/dist/Message.js +3 -2
  11. package/dist/Model.d.ts +5 -0
  12. package/dist/Model.js +263 -50
  13. package/dist/Package.js +2 -2
  14. package/dist/Price.js +6 -2
  15. package/dist/Product.d.ts +5 -3
  16. package/dist/Product.js +30 -18
  17. package/dist/Profile.d.ts +1 -0
  18. package/dist/Profile.js +91 -4
  19. package/dist/ProtoMap.d.ts +15 -8
  20. package/dist/ProtoMap.js +265 -139
  21. package/dist/ShoppingList.d.ts +30 -1
  22. package/dist/ShoppingList.js +345 -35
  23. package/dist/SyncCache.d.ts +9 -0
  24. package/dist/SyncCache.js +93 -30
  25. package/dist/SyncState.js +1 -1
  26. package/dist/Tax.d.ts +12 -4
  27. package/dist/Tax.js +33 -4
  28. package/dist/TaxHelper.d.ts +12 -0
  29. package/dist/TaxHelper.js +93 -0
  30. package/dist/TaxType.d.ts +7 -0
  31. package/dist/TaxType.js +32 -0
  32. package/dist/generated/AttachmentType.d.ts +19 -1
  33. package/dist/generated/AttachmentType.js +18 -0
  34. package/dist/generated/AuthTokens.d.ts +3 -1
  35. package/dist/generated/BusinessLink.d.ts +4 -1
  36. package/dist/generated/BusinessSyncInRequest.d.ts +11 -3
  37. package/dist/generated/BusinessSyncInResponse.d.ts +5 -4
  38. package/dist/generated/BusinessSyncOutRequest.d.ts +37 -10
  39. package/dist/generated/BusinessSyncOutRequest.js +42 -29
  40. package/dist/generated/BusinessSyncOutResponse.d.ts +14 -1
  41. package/dist/generated/Configuration.d.ts +2 -1
  42. package/dist/generated/CreateUserRequest.d.ts +8 -2
  43. package/dist/generated/Entity.d.ts +7 -3
  44. package/dist/generated/GeneralSettings.d.ts +14 -4
  45. package/dist/generated/GeneralSettings.js +9 -1
  46. package/dist/generated/Message.d.ts +4 -2
  47. package/dist/generated/Product.d.ts +10 -1
  48. package/dist/generated/Product.js +9 -1
  49. package/dist/generated/ProfileSyncOutRequest.d.ts +17 -1
  50. package/dist/generated/ProfileSyncOutResponse.d.ts +8 -3
  51. package/dist/generated/SalesOrderPayload.d.ts +7 -2
  52. package/dist/generated/ShoppingList.d.ts +6 -3
  53. package/dist/generated/ShoppingListItem.d.ts +21 -6
  54. package/dist/generated/Tax.d.ts +4 -4
  55. package/dist/generated/Tax.js +7 -8
  56. package/dist/generated/WorkingHoursRange.d.ts +2 -2
  57. package/dist/index.d.ts +2 -2
  58. package/dist/index.js +4 -3
  59. package/package.json +55 -55
  60. package/dist/generated/TaxType.d.ts +0 -25
  61. package/dist/generated/TaxType.js +0 -30
@@ -3,7 +3,7 @@ import GeneralSettings from "./GeneralSettings";
3
3
  import Package from "./Package";
4
4
  import Product from "./Product";
5
5
  import ShoppingList, { ShoppingListItem } from "./ShoppingList";
6
- import Tax from "./Tax";
6
+ import Tax, { TaxAmount } from "./Tax";
7
7
  export default class CartEditor {
8
8
  private readonly _catalog;
9
9
  private readonly _settings;
@@ -21,7 +21,7 @@ export default class CartEditor {
21
21
  get itemsTotal(): number;
22
22
  get savings(): number;
23
23
  get itemsTax(): number;
24
- get itemsFinalTotal(): number;
24
+ taxes(withDeliveryCharge?: boolean): ReadonlyArray<TaxAmount>;
25
25
  get isDeliveryOffered(): boolean;
26
26
  get deliveryCharge(): number;
27
27
  get regularDeliveryCharge(): number;
@@ -31,10 +31,13 @@ export default class CartEditor {
31
31
  total(withDeliveryCharge?: boolean): number;
32
32
  build(withDeliveryCharge?: boolean): ShoppingList;
33
33
  private buildDeliveryChargeItem;
34
+ private get regularCharge();
34
35
  private getDeliveryCharge;
35
- private static getTax;
36
36
  private getBuyablePackage;
37
- private static readonly MAX_QUANTITY;
37
+ private tryKeep;
38
+ private static toWireQuantity;
39
+ private checkQuantity;
40
+ private static readonly MAX_WIRE_QUANTITY;
38
41
  private readonly _inventoryLocationId;
39
42
  private readonly _unavailableItems;
40
43
  private readonly _quantities;
@@ -47,13 +50,15 @@ export declare class CartLine {
47
50
  get package(): Package;
48
51
  get product(): Product | null;
49
52
  get quantity(): number;
53
+ get item(): ShoppingListItem;
54
+ get taxes(): ReadonlyArray<Tax>;
50
55
  get unitPrice(): number;
51
56
  get regularUnitPrice(): number;
52
57
  get isOnOffer(): boolean;
53
- get taxes(): ReadonlyArray<Tax>;
54
58
  get amount(): number;
55
59
  get savings(): number;
56
60
  get totalTax(): number;
57
61
  get finalPrice(): number;
58
62
  toShoppingListItem(shoppingListOrder: number): ShoppingListItem;
63
+ private _item;
59
64
  }
@@ -1,10 +1,11 @@
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";
7
+ import ShoppingList, { ShoppingListItem, validatePricing } from "./ShoppingList";
8
+ import TaxHelper from "./TaxHelper";
8
9
  // Builds the cart a customer is filling, in the terms a UI has to hand: packages rather than
9
10
  // package ids, quantities rather than quantities multiplied by Constants.QUANTITY_FACTOR, and
10
11
  // prices taken off the catalog rather than worked out and passed in. Get one from
@@ -15,6 +16,15 @@ import ShoppingList, { ShoppingListItem } from "./ShoppingList";
15
16
  // store and nothing on the server until a built list is pushed. It is also a snapshot: it reads the
16
17
  // catalog, settings and cart once, at construction, so a sync arriving mid-edit does not move
17
18
  // prices under the customer. Make a fresh one when the Business republishes.
19
+ //
20
+ // EVERY PRICE HERE INCLUDES TAXES. The price of a package includes the taxes of its product, and
21
+ // the delivery charge the taxes the settings name for it, so what a customer sees in the catalog
22
+ // is what they pay, and every total below is a total with its taxes already in it. A shopping list
23
+ // item is the other way round - it stores a price WITHOUT taxes and works the taxes out on top -
24
+ // so building one takes the taxes out of the price, exactly as Java ShoppingListItem
25
+ // .setDetailsFromTaxInclusivePrices does, and all the arithmetic is Java's to the unit (see
26
+ // TaxHelper). The taxes are there to be shown as what the total includes (taxes), never to be
27
+ // added to it.
18
28
  class CartEditor {
19
29
  // Business.createCartEditor is how a UI gets one of these; this takes the three things it needs
20
30
  // rather than the Business itself, so a caller can build a cart against a catalog and settings
@@ -24,9 +34,14 @@ class CartEditor {
24
34
  // until it is ordered, the merchant may have repriced in between, and what an order freezes is
25
35
  // the price at the moment it is placed. Lines the catalog can no longer price - a package
26
36
  // 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.
37
+ // UI to tell the customer about. So is a line that cannot be bought as it is, which setQuantity
38
+ // would have refused, a cart that was read not being known to be one this class could have made:
39
+ // a quantity that is nothing or is not one, a fraction of what is not sold loose, more than the
40
+ // wire carries, or that comes to more than the protocol can at today's price, and any line past
41
+ // the most a cart holds (the first lines are kept, being in the order picked). The charge lines
42
+ // of the list it started from (see ShoppingListItem.isReservedPackage) are dropped too, being
43
+ // this class's to work out rather than a customer's to carry: build() puts back whatever the
44
+ // settings now say.
30
45
  constructor(_catalog, _settings, cart) {
31
46
  this._catalog = _catalog;
32
47
  this._settings = _settings;
@@ -34,15 +49,14 @@ class CartEditor {
34
49
  // gives each line its shoppingListOrder
35
50
  this._quantities = new Map();
36
51
  this._inventoryLocationId = cart?.inventoryLocationId ?? 0;
52
+ // In the order of the list, which is the order the customer picked them in: the goods items are in it, and the items
53
+ // of the list are not, the server sending those by package id, so a cart that is read back would reorder itself
37
54
  const unavailableItems = [];
38
- for (const item of cart?.existingItems ?? []) {
55
+ for (const item of cart?.goodsItems ?? []) {
39
56
  if (item.isReservedPackage)
40
57
  continue;
41
- const buyable = this.getBuyablePackage(item.packageId);
42
- if (buyable === null)
58
+ if (!this.tryKeep(item))
43
59
  unavailableItems.push(item);
44
- else
45
- this._quantities.set(buyable.id, item.quantity);
46
60
  }
47
61
  this._unavailableItems = Object.freeze(unavailableItems);
48
62
  }
@@ -84,26 +98,17 @@ class CartEditor {
84
98
  // Sets how much of one package the cart holds; zero takes it out. A package a customer cannot
85
99
  // buy - one disabled, or priced in no list this storefront sells from - is refused, as is a
86
100
  // 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.
101
+ // carry. Whether a package is sold loose is the catalog's word, and not that of the Package
102
+ // passed in, which may be one held from before the catalog changed. Returns this, so edits chain.
88
103
  setQuantity(item, quantity) {
89
- if (!Number.isFinite(quantity) || quantity < 0)
90
- throw new InvalidArgumentException(`${quantity} is not a quantity`);
91
- if (quantity === 0) {
104
+ const wireQuantity = CartEditor.toWireQuantity(quantity);
105
+ // Less than half a thousandth is nothing on the wire - a line of zero, which would cost nothing
106
+ // - so it takes the line out, as zero does
107
+ if (wireQuantity === 0) {
92
108
  this._quantities.delete(item.id);
93
109
  return this;
94
110
  }
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);
111
+ this._quantities.set(item.id, this.checkQuantity(item.id, quantity, wireQuantity));
107
112
  return this;
108
113
  }
109
114
  // Adds to what the cart already holds of this package, which is what a quantity stepper does.
@@ -118,52 +123,44 @@ class CartEditor {
118
123
  this._quantities.clear();
119
124
  return this;
120
125
  }
121
- // What the packages cost before tax, offers already taken off
126
+ // What the packages come to, taxes included and offers already taken off: the sum of the final
127
+ // prices of the lines, and what a customer sees to be the price of what is in the cart. Delivery
128
+ // is not in it.
122
129
  get itemsTotal() {
123
- let result = 0;
124
- for (const line of this.lines)
125
- result += line.amount;
126
- return result;
130
+ return ShoppingList.sumOfFinalPrices(this.lines.map((line) => line.item));
127
131
  }
128
- // What the offers took off, which is what a bill shows as savings. Zero when nothing is on offer.
132
+ // What the offers took off the packages, which is what a bill shows as savings: what they would
133
+ // have come to at their regular prices, less what they come to. Zero when nothing is on offer.
134
+ // Taxes are included on both sides.
129
135
  get savings() {
130
- let result = 0;
131
- for (const line of this.lines)
132
- result += line.savings;
133
- return result;
136
+ return this.build().savings;
134
137
  }
138
+ // The taxes inside itemsTotal, all types together. They are part of the total and are not
139
+ // added to it: see taxes.
135
140
  get itemsTax() {
136
- let result = 0;
137
- for (const line of this.lines)
138
- result += line.totalTax;
139
- return result;
141
+ return this.build().computedTotalTax;
140
142
  }
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;
143
+ // The taxes the cart's total includes, one entry per TYPE of tax - a bill shows a line for each
144
+ // that is not zero. withDeliveryCharge is as for total: the taxes in the delivery charge are
145
+ // counted only when it is.
146
+ taxes(withDeliveryCharge = false) {
147
+ return this.build(withDeliveryCharge).computedTaxes;
148
148
  }
149
149
  // True when this business delivers at all. False means no delivery charge exists to add, whatever
150
150
  // build() is asked for, and a UI has no fulfillment choice to offer either.
151
151
  get isDeliveryOffered() {
152
152
  return this._settings.fulfillmentModes.includes(proto_FulfillmentMode.DELIVERY);
153
153
  }
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.
154
+ // What delivering this cart would cost, taxes included: the business's flat charge, or zero
155
+ // where it has been waived by the cart reaching freeDeliveryTotal, or where the business delivers
156
+ // free anyway. Always zero when the business does not deliver.
157
157
  get deliveryCharge() {
158
- return this.getDeliveryCharge(this.itemsFinalTotal);
158
+ return this.buildDeliveryChargeItem(this.getDeliveryCharge(this.itemsTotal), 0)?.finalPrice ?? 0;
159
159
  }
160
160
  // What delivery costs before any waiver - the price a bill strikes through when it reads FREE.
161
161
  // Zero where the business delivers free anyway, in which case there is nothing to strike through.
162
162
  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);
163
+ return this.buildDeliveryChargeItem(this.regularCharge, 0)?.finalPrice ?? 0;
167
164
  }
168
165
  // True when delivering this cart costs nothing, whether because the business never charges or
169
166
  // because this cart has reached the threshold
@@ -176,40 +173,45 @@ class CartEditor {
176
173
  get amountToFreeDelivery() {
177
174
  if (!this.isDeliveryOffered)
178
175
  return null;
179
- if (this.regularDeliveryCharge === 0)
176
+ if (this.regularCharge === 0)
180
177
  return 0; // Already free, and free at any total
181
178
  const freeDeliveryTotal = this._settings.freeDeliveryTotal;
182
179
  if (freeDeliveryTotal <= 0)
183
180
  return null; // No threshold: adding more changes nothing
184
- return Math.max(0, freeDeliveryTotal - this.itemsFinalTotal);
181
+ const remaining = amountToScaledValue(freeDeliveryTotal) - amountToScaledValue(this.itemsTotal);
182
+ return remaining > 0n ? amountFromScaledValue(remaining) : 0;
185
183
  }
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.
184
+ // Tax on the delivery charge: how much of deliveryCharge is the taxes GeneralSettings
185
+ // .deliveryChargeTaxes carries - which are the business's own and have nothing to do with a
186
+ // product's. Included in the charge, not added to it. Zero when delivery is free, there being
187
+ // nothing to tax.
189
188
  get deliveryTax() {
190
- return CartEditor.getTax(this.deliveryCharge, this._settings.deliveryChargeTaxes);
189
+ return this.buildDeliveryChargeItem(this.getDeliveryCharge(this.itemsTotal), 0)?.totalTax ?? 0;
191
190
  }
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.
191
+ // What the cart comes to, taxes included. Delivery is left out unless asked for, the way a cart
192
+ // page leaves it out and a checkout page puts it in - the same choice build() takes. It is the
193
+ // total of the list build() answers, so it is the total an order placed from it carries.
194
194
  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);
195
+ return this.build(withDeliveryCharge).totalAmount;
202
196
  }
203
197
  // 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.
198
+ // what a later Model.createOrder turns into an order. It is also the list to SHOW - every price,
199
+ // total and tax a cart or a checkout page displays can be read off it, which is how a placed
200
+ // order is shown too.
201
+ //
202
+ // Each line is an item that stores the price without taxes, as Java does, and is flagged
203
+ // original: a customer's lines are created before the list is final (Java ShoppingList.addItem
204
+ // sets isOriginal to !isFinal(), and the order becomes final only after its items are in it), and
205
+ // an item that is not original is what the merchant app marks as added by the business.
205
206
  //
206
207
  // withDeliveryCharge decides whether the delivery charge rides along as a line of its own,
207
208
  // carrying ReservedPackage.DELIVERY_CHARGE_PACKAGE and flagged by
208
209
  // ShoppingList.autoAddDeliveryCharge. A cart page has no fulfillment chosen yet and so leaves it
209
210
  // 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.
211
+ // is added even when the charge has been waived - at zero, with the regular charge (less its
212
+ // taxes, like every price in an item) as its priceDiscount - so a bill can show FREE with the
213
+ // original struck through, which is what Java ShoppingCartViewModel.setDeliveryCharge writes
214
+ // too.
213
215
  //
214
216
  // Nothing is added to an empty cart: a list holding a delivery charge and no goods is not an
215
217
  // order, and Java removes exactly that line when it is all that is left.
@@ -219,58 +221,128 @@ class CartEditor {
219
221
  const items = [];
220
222
  for (const line of this.lines)
221
223
  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));
224
+ const delivery = withDeliveryCharge && items.length > 0
225
+ ? this.buildDeliveryChargeItem(this.getDeliveryCharge(ShoppingList.sumOfFinalPrices(items)), items.length)
226
+ : null;
227
+ if (delivery !== null)
228
+ items.push(delivery);
225
229
  // The total is written off the lines being sent, the way ShoppingList.proto defines it: the
226
230
  // sum of their final prices, tax included. There is no list-wide discount - an offer comes off
227
231
  // 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;
232
+ 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([]));
233
+ }
234
+ // The delivery charge as a line: one of it, at what it costs now (charge - the regular charge, or
235
+ // zero once waived), with the waived amount as its price discount - the same shape as a package
236
+ // bought on offer, which is what lets a bill render it with no special case beyond its label.
237
+ // Null when the business does not deliver. The charge in the settings includes the settings'
238
+ // delivery charge taxes, so they come out of it like those of any other price.
239
+ buildDeliveryChargeItem(charge, shoppingListOrder) {
240
+ if (!this.isDeliveryOffered)
241
+ return null;
242
+ return buildItem(Ids.buildId(proto_Constants.PACKAGE_ENTITY_TYPE_ID, BigInt(proto_ReservedPackage.DELIVERY_CHARGE_PACKAGE)), 1, charge, this.regularCharge, this._settings.deliveryChargeTaxes, shoppingListOrder);
243
+ }
244
+ // The charge before any waiver. Java clamps a negative charge to zero rather than treating it as
245
+ // a refund, and so does this. Zero when the business does not deliver.
246
+ get regularCharge() {
247
+ if (!this.isDeliveryOffered)
248
+ return 0;
249
+ return Math.max(0, this._settings.deliveryCharge);
250
+ }
251
+ // The charge against a cart whose goods come to this much, which is the whole of the policy: the
252
+ // regular charge, or nothing once the goods reach the threshold. Java ShoppingCartViewModel
253
+ // .calculateDeliveryCharge compares the same total, taxes included and the delivery line left out.
254
+ getDeliveryCharge(itemsTotal) {
255
+ const charge = this.regularCharge;
244
256
  if (charge === 0)
245
257
  return 0; // Free for everyone, or not delivered at all
246
258
  const freeDeliveryTotal = this._settings.freeDeliveryTotal;
247
259
  if (freeDeliveryTotal <= 0)
248
260
  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;
261
+ return itemsTotal >= freeDeliveryTotal ? 0 : charge;
256
262
  }
257
263
  // The package this id names, and null unless a customer could actually buy it right now
258
264
  getBuyablePackage(packageId) {
259
265
  const item = this._catalog.getPackage(packageId);
260
266
  return item !== null && item.isBuyable ? item : null;
261
267
  }
268
+ // Keeps a line of a cart that was read, as setQuantity would, and says whether it could: a line
269
+ // that cannot be kept is not a caller's mistake to be refused but one for unavailableItems
270
+ tryKeep(item) {
271
+ try {
272
+ const wireQuantity = CartEditor.toWireQuantity(item.quantity);
273
+ if (wireQuantity === 0)
274
+ return false; // A line of nothing is no line
275
+ this._quantities.set(item.packageId, this.checkQuantity(item.packageId, item.quantity, wireQuantity));
276
+ return true;
277
+ }
278
+ catch (error) {
279
+ if (error instanceof InvalidArgumentException)
280
+ return false;
281
+ throw error;
282
+ }
283
+ }
284
+ // A quantity travels as an int32 multiplied by Constants.QUANTITY_FACTOR (Java rounds it to that:
285
+ // NumberHelper.floatToInt), so the least there is is a thousandth. This is a quantity as that,
286
+ // and what is kept is the quantity the wire will hold, so getQuantity never reports one the
287
+ // order will not have.
288
+ static toWireQuantity(quantity) {
289
+ if (!Number.isFinite(quantity) || quantity < 0)
290
+ throw new InvalidArgumentException(`${quantity} is not a quantity`);
291
+ return Math.round(quantity * proto_Constants.QUANTITY_FACTOR);
292
+ }
293
+ // The quantity to keep for this much of a package, which is what the wire will hold of it - or an
294
+ // InvalidArgumentException saying why the cart cannot have it: the package is not one the catalog
295
+ // sells, or it is not sold loose and the quantity is a fraction, or the quantity, or what it comes
296
+ // to, is more than the protocol carries, or the cart is full. All of it is read off the catalog's
297
+ // package, which is what the order will be priced from, and not off the caller's.
298
+ checkQuantity(packageId, quantity, wireQuantity) {
299
+ const buyable = this.getBuyablePackage(packageId);
300
+ if (buyable === null)
301
+ throw new InvalidArgumentException(`Package ${Ids.toStorageHexString(packageId)} cannot be bought from this catalog`);
302
+ if (!buyable.isSoldLoose && !Number.isInteger(quantity))
303
+ throw new InvalidArgumentException(`${buyable.name} is not sold loose, so it cannot be bought ${quantity} at a time`);
304
+ // This is the most of one package a cart can hold, and it is refused here rather than silently
305
+ // wrapping on the wire
306
+ if (wireQuantity > CartEditor.MAX_WIRE_QUANTITY)
307
+ throw new InvalidArgumentException(`${quantity} is more than the ${CartEditor.MAX_WIRE_QUANTITY / proto_Constants.QUANTITY_FACTOR} the protocol can carry`);
308
+ // A list holds at most Constants.SHOPPING_LIST_MAX_ITEMS items, and the delivery charge is one
309
+ // of them (Java ShoppingList.addItem counts it like any other, and a server refuses a list a
310
+ // line over). So where the business delivers, one place is kept for it: a cart that could be
311
+ // filled to the limit could not be ordered for delivery.
312
+ const maxLines = proto_Constants.SHOPPING_LIST_MAX_ITEMS - (this.isDeliveryOffered ? 1 : 0);
313
+ if (!this._quantities.has(packageId) && this._quantities.size >= maxLines)
314
+ throw new InvalidArgumentException(`A cart holds at most ${maxLines} lines`);
315
+ // Java refuses an amount past Transaction.MAX_AMOUNT (validateAmount) in the item that comes to
316
+ // it, and so does pricing an item here, which is done before the line is kept rather than when
317
+ // a list cannot be written
318
+ const stored = wireQuantity / proto_Constants.QUANTITY_FACTOR;
319
+ try {
320
+ validatePricing(new CartLine(buyable, this._catalog.getProduct(buyable.productId), stored).item);
321
+ }
322
+ catch (error) {
323
+ if (error instanceof InvalidArgumentException)
324
+ throw new InvalidArgumentException(`${quantity} of ${buyable.name} comes to more than the protocol can carry (${error.message})`);
325
+ throw error;
326
+ }
327
+ return stored;
328
+ }
262
329
  }
263
- // What an int32 quantity multiplied by Constants.QUANTITY_FACTOR reaches
264
- CartEditor.MAX_QUANTITY = Math.floor(0x7fffffff / proto_Constants.QUANTITY_FACTOR);
330
+ // The most an int32 holds, which is the most that a quantity multiplied by
331
+ // Constants.QUANTITY_FACTOR can be
332
+ CartEditor.MAX_WIRE_QUANTITY = 0x7fffffff;
265
333
  export default CartEditor;
266
334
  // 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.
335
+ // - with the prices the catalog gives it now, taxes included. A read-only view over what
336
+ // CartEditor holds, rebuilt on every read of CartEditor.lines rather than kept.
337
+ //
338
+ // Everything here is read off item, the line as a shopping list would store it, so the cart page
339
+ // shows exactly what the order will: no price is worked out twice.
269
340
  export class CartLine {
270
341
  constructor(_package, _product, _quantity) {
271
342
  this._package = _package;
272
343
  this._product = _product;
273
344
  this._quantity = _quantity;
345
+ this._item = null;
274
346
  }
275
347
  get package() {
276
348
  return this._package;
@@ -282,43 +354,78 @@ export class CartLine {
282
354
  get quantity() {
283
355
  return this._quantity;
284
356
  }
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.
357
+ // This line as a shopping list holds it: priced without taxes, with the taxes beside it. Show
358
+ // this to show the line the way an order shows it. Its place in the list is not meaningful here.
359
+ get item() {
360
+ this._item ?? (this._item = this.toShoppingListItem(0));
361
+ return this._item;
362
+ }
363
+ // The taxes of the product this package belongs to, which its price includes. A rate that is not
364
+ // greater than zero is no tax, as in Java, and is not here.
365
+ get taxes() {
366
+ return includedTaxes(this._product?.taxes ?? []);
367
+ }
368
+ // What one unit costs, taxes included: the offer price where the package is on offer, else the
369
+ // regular price. This is the catalog's price, to within the rounding of taxes that a price with
370
+ // taxes in it is subject to (a thousandth or two, which no one is shown). A buyable package
371
+ // always has a price, so this is never guessed.
287
372
  get unitPrice() {
288
- return this._package.price?.finalPrice ?? 0;
373
+ return this.item.unitPriceWithTaxes;
289
374
  }
375
+ // The regular price of a unit, taxes included - what is struck through when it is on offer
290
376
  get regularUnitPrice() {
291
- return this._package.price?.regularPrice ?? 0;
377
+ return this.item.regularUnitPriceWithTaxes;
292
378
  }
293
379
  get isOnOffer() {
294
380
  return this._package.price?.isOnOffer ?? false;
295
381
  }
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
382
+ // What this line costs before its taxes: its tax base. A bill that shows prices with taxes shows
383
+ // finalPrice, not this.
301
384
  get amount() {
302
- return this.unitPrice * this._quantity;
385
+ return this.item.amount;
303
386
  }
304
- // What the offer takes off this line
387
+ // What the offer takes off this line, taxes included
305
388
  get savings() {
306
- return (this.regularUnitPrice - this.unitPrice) * this._quantity;
389
+ const saved = amountToScaledValue(this.item.finalPriceWithoutDiscounts) -
390
+ amountToScaledValue(this.item.finalPrice);
391
+ return saved > 0n ? amountFromScaledValue(saved) : 0;
307
392
  }
393
+ // The taxes inside finalPrice
308
394
  get totalTax() {
309
- let result = 0;
310
- for (const tax of this.taxes)
311
- result += (this.amount * tax.rate) / 100;
312
- return result;
395
+ return this.item.totalTax;
313
396
  }
314
- // What this line adds to the cart: what it costs plus the tax on it
397
+ // What this line adds to the cart, and what a customer pays for it: the price of a unit
398
+ // multiplied by the quantity, with its taxes already in it
315
399
  get finalPrice() {
316
- return this.amount + this.totalTax;
400
+ return this.item.finalPrice;
317
401
  }
318
402
  // This line as the protocol carries it. Only CartEditor.build calls this, which is what assigns
319
- // the order.
403
+ // the order. Taxes come out of the price on the way (see buildItem).
320
404
  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);
405
+ const price = this._package.price;
406
+ return buildItem(this._package.id, this._quantity, price?.finalPrice ?? 0, price?.regularPrice ?? 0, this._product?.taxes ?? [], shoppingListOrder);
322
407
  }
323
408
  }
324
- const EMPTY_TAXES = Object.freeze([]);
409
+ // The taxes a price includes, as an item is to carry them: only those with a rate, which is what
410
+ // Java does with the taxes of a product (TaxHelper.getTaxCodesAndRates drops the rest).
411
+ function includedTaxes(taxes) {
412
+ return Object.freeze(taxes.filter((tax) => Number.isFinite(tax.rate) && tax.rate > 0));
413
+ }
414
+ // The one place a price with taxes in it becomes a shopping list item, and so the one place
415
+ // CartEditor takes a tax out of a price. The item stores the prices without them, as Java
416
+ // ShoppingListItem.setDetailsFromTaxInclusivePrices does: the offer price and the regular price
417
+ // each have their taxes removed, and what the offer takes off is the difference of the two - which
418
+ // is not the difference of the prices with taxes. Everything but the price is as Java's
419
+ // ProductSelectionCard leaves a new item: unit quantity or the customer's, no discount of its own,
420
+ // no description (that is for lines that have no package), and no stock keeping or expiry, a
421
+ // storefront being told neither. It is flagged original, for the reason build gives.
422
+ //
423
+ // A price is a whole number of thousandths and each tax is rounded, so some prices cannot be
424
+ // reached: the item then has the price nearest to it (TaxHelper.removeTaxes).
425
+ function buildItem(packageId, quantity, offerPriceWithTaxes, regularPriceWithTaxes, taxes, shoppingListOrder) {
426
+ const included = includedTaxes(taxes);
427
+ const rates = TaxHelper.scaledRatesOf(included);
428
+ const offerPrice = TaxHelper.removeTaxes(amountToScaledValue(offerPriceWithTaxes), rates);
429
+ const regularPrice = TaxHelper.removeTaxes(amountToScaledValue(regularPriceWithTaxes), rates);
430
+ return new ShoppingListItem(packageId, quantity, amountFromScaledValue(offerPrice), shoppingListOrder, true, false, false, amountFromScaledValue(regularPrice > offerPrice ? regularPrice - offerPrice : 0n), 0, included, 0, null);
431
+ }
package/dist/Catalog.js CHANGED
@@ -12,28 +12,33 @@ class Catalog {
12
12
  // A dummy category standing in for a product whose parentItemId is Constants.ENTITY_ID_NULL.
13
13
  // Its own id is that same ENTITY_ID_NULL, so getCategory(product.productCategoryId) resolves
14
14
  // it exactly like a real category - including being left out below, the same as any other
15
- // category, when nothing buyable ends up in it.
15
+ // category, when no product ends up in it.
16
16
  const uncategorized = new ProductCategory(new EntityHeader(BigInt(proto_Constants.ENTITY_ID_NULL), 0n, 0n, 0n, 0n), "", null, BigInt(proto_Constants.ENTITY_ID_NULL), false);
17
- this._products = Object.freeze([...products]);
18
- // Only a buyable product - one a customer can actually order - counts towards a category. A
19
- // product that cannot be bought right now keeps its place in Catalog.products and stays
20
- // reachable by getProduct, but does not keep a category alive on its own.
17
+ // Only a buyable product - itself enabled, not one that the business sells only offline, with at
18
+ // least one buyable package, so one a customer can actually order - is part of a catalog. Any other is left out of products, getProduct and
19
+ // its categories, and so are its packages: they are not reachable by id either. This is
20
+ // enforced here, and not left to whoever builds the catalog to have filtered, so that no way
21
+ // of making one can expose a product that cannot be bought.
22
+ const buyableProducts = [];
21
23
  const productsByCategory = new Map();
22
- for (const product of this._products) {
24
+ for (const product of products) {
25
+ if (!product.isBuyable)
26
+ continue;
27
+ buyableProducts.push(product);
23
28
  this._productMap.set(product.id, product);
24
- if (product.isBuyable) {
25
- const productsOfCategory = productsByCategory.get(product.productCategoryId);
26
- if (productsOfCategory === undefined)
27
- productsByCategory.set(product.productCategoryId, [product]);
28
- else
29
- productsOfCategory.push(product);
30
- }
29
+ const productsOfCategory = productsByCategory.get(product.productCategoryId);
30
+ if (productsOfCategory === undefined)
31
+ productsByCategory.set(product.productCategoryId, [product]);
32
+ else
33
+ productsOfCategory.push(product);
31
34
  for (const pkg of product.packages)
32
35
  this._packageMap.set(pkg.id, pkg);
33
36
  }
34
- // A category with nothing a customer can order is left out rather than shown empty - there is
35
- // no point showing it to a customer. Nothing here is special-cased for the dummy: it is left
36
- // out exactly the same way when nothing buyable is uncategorized.
37
+ this._products = Object.freeze(buyableProducts);
38
+ // A category with nothing a customer can order, which is one that no product reached above, is
39
+ // left out rather than shown empty - there is no point showing it to a customer. Nothing here
40
+ // is special-cased for the dummy: it is left out exactly the same way when nothing buyable is
41
+ // uncategorized.
37
42
  const nonEmptyCategories = [];
38
43
  for (const category of [...categories, uncategorized]) {
39
44
  const categoryProducts = productsByCategory.get(category.id);
@@ -50,7 +55,8 @@ class Catalog {
50
55
  get categories() {
51
56
  return this._categories;
52
57
  }
53
- // Every product of the business, whether or not it is in a category, in no particular order
58
+ // Every buyable product of the business - itself enabled, with at least one buyable package -
59
+ // whether or not it is in a category, in no particular order. A product that is not is not here.
54
60
  get products() {
55
61
  return this._products;
56
62
  }
@@ -69,11 +75,13 @@ class Catalog {
69
75
  getCategory(productCategoryId) {
70
76
  return this._categoryMap.get(productCategoryId) ?? null;
71
77
  }
78
+ // Null for an unknown id, and for a product that is not buyable, which this catalog does not hold
72
79
  getProduct(productId) {
73
80
  return this._productMap.get(productId) ?? null;
74
81
  }
75
82
  // Returns null for the four reserved package ids a shopping list uses for its charge and
76
- // summary lines (see Ids.isReservedPackageId), and for a package whose product was deleted
83
+ // summary lines (see Ids.isReservedPackageId), and for a package whose product was deleted or is
84
+ // not buyable
77
85
  getPackage(packageId) {
78
86
  return this._packageMap.get(packageId) ?? null;
79
87
  }