@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
package/dist/SyncCache.js CHANGED
@@ -1,66 +1,114 @@
1
+ import { Constants as proto_Constants } from "./generated/Constants";
1
2
  import Ids from "./Ids";
2
3
  import * as ProtoMap from "./ProtoMap";
3
- // What the server has already said, so that the next call need only ask for what has changed since. A read cache and nothing else: everything in it came out of a response, so giving a container up costs one refetch.
4
- // Not write through, not an identity (it stamps nothing) and not a sync - a caller calls the Service and hands the response in, which is why the cursors are the caller's. It holds the wire and only the wire, and ProtoMap reads every field. See SYNC LAYER in ../Prompt.txt.
5
- // One response is one write: the entities it brought are stored and the entities its tombstones
6
- // named are deleted, in a single batch. There is nothing to work out beyond that, because the
7
- // server sends a tombstone for every entity it deleted, children included - so this cache never
8
- // infers a deletion from another one, and a write costs one transaction whether or not the page
9
- // happened to carry a tombstone.
4
+ import * as SyncKeys from "./SyncKeys";
5
+ // What the server has already said, so the next call asks only for what changed. A read cache:
6
+ // everything in it came from a response, so giving up a container costs one refetch.
7
+ // It does not write through or sync. The caller calls the Service, does the paging and says which
8
+ // writes are reads. It holds only the wire, which ProtoMap reads. See SYNC LAYER in ../Prompt.txt.
9
+ // - One response is one write. A tombstone is a blob nothing older replaces. The server sends one
10
+ // for every deleted entity, children included, so the cache never infers a deletion.
11
+ // - Tombstones are stored as sent, so a deleted message keeps its order (a completed order is a
12
+ // deleted message). readDeletedBusiness, readDeletedProfile and readDeletedOrders answer them.
13
+ // readOrders and readPartyPreferences leave them out.
14
+ // - A cursor is a blob per entity type (its eTag is the cursor), not derived from the entities,
15
+ // since tombstones and this client's own changes would move it. It rises only with a page of a
16
+ // read, in that page's batch, and the store keeps the greater.
10
17
  export default class SyncCache {
11
18
  constructor(_database) {
12
19
  this._database = _database;
13
20
  this._containers = new Map();
14
21
  }
15
- // Everything cached for one business, as the response that carried it, or null before anything has been written for it.
22
+ // Everything cached for one business, as the response that carried it, or null before anything is
23
+ // written. Tombstones are included as sent: a deleted entity carries what it was, or only its header
24
+ // for an item. Whatever builds on this must leave tombstones out, except for the order of a message.
16
25
  async readBusiness(containerName) {
17
26
  return ProtoMap.businessResponseFromBlobs(await this.readAll(containerName));
18
27
  }
19
- // Takes in what one business sync out returned, one blob per entity versioned by Entity.updatedAt, so the later of two copies wins and a page that arrives twice changes nothing the second time.
20
- // What its tombstones named goes in the same batch, deleted rather than stored: a page brings what
21
- // has changed and says what has gone, and applying half of that would leave the cache holding an
22
- // entity the server has already reported dead.
23
- // Answers how much of the page it kept - blobs stored plus ids deleted, which is what the container
24
- // now differs by. That is how a caller tells a response that changed something from one that
25
- // changed nothing, and what it publishes on: the store keeps a blob only when its eTag is later, so
26
- // a response can be rejected in full, and one that was cannot change what a later read of this
27
- // container answers - see MODEL.
28
+ // The cursors the next read sends: the highest update number of each entity type that a page of a
29
+ // read brought, tombstones included, or zero for a type none has brought. Only a read moves them, so
30
+ // the next read brings back what this client pushed.
31
+ async readBusinessCursors(containerName) {
32
+ return this.readCursors(containerName, SyncKeys.BUSINESS_SYNC_IN);
33
+ }
34
+ // Stores one page of a business read, a blob per entity versioned by ProtoMap.entityETag, so the later
35
+ // copy wins and a repeated page changes nothing. A tombstone wins over the copies of what it deleted,
36
+ // since a delete is a later write. The cursors the page raises go in the same batch, so they cannot
37
+ // get ahead of their entities and an interrupted read resumes from its last page.
38
+ // Answers how many blobs it stored, which tells a caller whether the response changed anything and
39
+ // what to publish on. The store keeps a blob only when its eTag is later, so a response can be
40
+ // rejected in full, and a rejected one cannot change what a later read answers - see MODEL.
28
41
  async writeBusiness(containerName, response) {
42
+ return this.writeRead(containerName, ProtoMap.businessResponseToCacheWrite(response), SyncKeys.BUSINESS_SYNC_IN, (cursors) => ProtoMap.raiseBusinessCursors(cursors, response));
43
+ }
44
+ // Stores what a business response brought that was not read. It moves no cursor, since what is merged
45
+ // here is not something this container was read for. Otherwise the same as writeBusiness.
46
+ async storeBusiness(containerName, response) {
29
47
  return this.write(containerName, ProtoMap.businessResponseToCacheWrite(response));
30
48
  }
49
+ // The tombstones cached for one business, as a response, each as stored and as the server sent it.
50
+ // Null when there are none. The entities a deleted entity owned are tombstones of their own.
51
+ async readDeletedBusiness(containerName) {
52
+ return ProtoMap.deletedBusinessResponseFromBlobs(await this.readAll(containerName));
53
+ }
31
54
  // The same for one profile: the customer, the businesses they may reach, and the link to each.
32
55
  async readProfile(containerName) {
33
56
  return ProtoMap.profileResponseFromBlobs(await this.readAll(containerName));
34
57
  }
58
+ // The tombstones of a profile, as readDeletedBusiness answers those of a business.
59
+ async readDeletedProfile(containerName) {
60
+ return ProtoMap.deletedProfileResponseFromBlobs(await this.readAll(containerName));
61
+ }
62
+ async readProfileCursors(containerName) {
63
+ return this.readCursors(containerName, SyncKeys.PROFILE_SYNC_IN);
64
+ }
65
+ // One page of a profile read - see writeBusiness
35
66
  async writeProfile(containerName, response) {
67
+ return this.writeRead(containerName, ProtoMap.profileResponseToCacheWrite(response), SyncKeys.PROFILE_SYNC_IN, (cursors) => ProtoMap.raiseProfileCursors(cursors, response));
68
+ }
69
+ // What a call that is not a read answered with (a sign up, an update). It is a change this client
70
+ // made, so it moves no cursor and the next read brings it back. Otherwise the same as writeProfile.
71
+ async storeProfile(containerName, response) {
36
72
  return this.write(containerName, ProtoMap.profileResponseToCacheWrite(response));
37
73
  }
38
- // The credentials that sign calls about this container - the user token in a profile container, the link token in a business one - and null before the server has issued either.
74
+ // The credentials that sign calls about this container (the user token in a profile container, the
75
+ // link token in a business one), or null before the server issues either.
39
76
  async readAuthTokens(containerName) {
40
77
  const blob = await (await this.getContainer(containerName)).read(Ids.AUTH_TOKENS_BLOB_ID);
41
78
  if (blob === null)
42
79
  return null;
43
80
  return ProtoMap.authTokensFromBytes(blob.bytes);
44
81
  }
45
- // Unconditional, unlike everything else here: a reissued token is not a later version of an earlier one, so there is nothing to compare and the latest wins.
46
- // Keeps only what this server issued - see ProtoMap.authTokensToBlob for why the authentication token is dropped whatever is passed in.
82
+ // Written without comparison, unlike everything else here: a reissued token is not a later version of
83
+ // the old one, so the latest wins. Keeps only what this server issued - see ProtoMap.authTokensToBlob
84
+ // for why the authentication token is dropped.
47
85
  async writeAuthTokens(containerName, authTokens) {
48
86
  await this.writeBlobs(containerName, [ProtoMap.authTokensToBlob(authTokens)]);
49
87
  }
50
- // The orders of whoever the responses written here answered, since a server sends a caller only their own messages.
51
- // Nothing here records which caller that was, or notices when it changes - see THE MESSAGES OF A BUSINESS ARE NOT PUBLIC.
88
+ // The open orders of whoever the written responses answered, since a server sends a caller only their
89
+ // own messages. Open means the business has not completed them; completed ones are deleted messages,
90
+ // which readDeletedOrders answers. Nothing here records which caller that was or notices when it
91
+ // changes. See THE MESSAGES OF A BUSINESS ARE NOT PUBLIC.
52
92
  async readOrders(containerName) {
53
93
  return ProtoMap.orderMessagesFromBlobs(await this.readAll(containerName));
54
94
  }
55
- // The cursor a push sends: the highest update number among this customer's messages already
56
- // cached here, party preferences and orders alike. Not persisted, like every cursor in this
57
- // library - read off the store fresh each time rather than kept anywhere.
95
+ // The orders the business has completed, by fulfilling or cancelling. The merchant app completes an
96
+ // order by deleting its message, so each is a tombstone, but one a server sends whole because the
97
+ // customer is shown the order afterwards. Each is as stored and as sent. A tombstone with no order is
98
+ // not one of them.
99
+ async readDeletedOrders(containerName) {
100
+ return ProtoMap.deletedOrderMessagesFromBlobs(await this.readAll(containerName));
101
+ }
102
+ // The cursor a push sends: how far reads of this container have got with this customer's messages,
103
+ // party preferences and orders alike. It is the cursor the next read sends. What a push stores here
104
+ // does not move it, so a change made during the push is not skipped.
58
105
  async readMessageUpdateNumber(containerName) {
59
- return ProtoMap.highestCachedMessageUpdateNumber(await this.readAll(containerName));
106
+ return (await this.readBusinessCursors(containerName)).getUpdateNumber(proto_Constants.MESSAGE_ENTITY_TYPE_ID);
60
107
  }
61
- // Refuses a message that is not an order, since only the payload says which it is: one stored as the other would never be read back.
108
+ // Refuses a message that is not an order. Only the payload says which it is, and one stored as the
109
+ // other would never be read back.
62
110
  async writeOrder(containerName, order) {
63
- return this.writeBlobs(containerName, [ProtoMap.orderToBlob(order)]);
111
+ return this.writeOwnMessage(containerName, order, ProtoMap.orderToBlob);
64
112
  }
65
113
  // The party preferences message in this container, or null when there is none. Two mean two
66
114
  // callers, which is the same open question as the orders above.
@@ -68,49 +116,61 @@ export default class SyncCache {
68
116
  return ProtoMap.partyPreferencesMessageFromBlobs(await this.readAll(containerName));
69
117
  }
70
118
  async writePartyPreferences(containerName, partyPreferences) {
71
- return this.writeBlobs(containerName, [ProtoMap.partyPreferencesToBlob(partyPreferences)]);
72
- }
73
- // Drops the party preferences message this container holds, and answers zero when it holds none.
74
- // The one delete here that no tombstone asked for, and the only reason it exists: one written
75
- // before anybody signed in is handed over to the container named for the membership, and the
76
- // copy left behind would otherwise be read by the next anonymous visitor and by the next customer
77
- // to sign in - see Model.businessSyncIn. Nothing else about a container is a caller's to remove:
78
- // everything in one came off the wire, and what the server has not said is gone stays.
119
+ return this.writeOwnMessage(containerName, partyPreferences, ProtoMap.partyPreferencesToBlob);
120
+ }
121
+ // Gives up everything cached for a business: entities, cursors and tokens. For when a container turns
122
+ // out to hold another business than the one it is named for, which can happen because a businessId is
123
+ // only how a business is found. Only the id of its Business tells them apart, and a cursor of one is
124
+ // no cursor of the other: the store keeps a blob only when its eTag is greater, so the old cursors
125
+ // would never be lowered and what the new business has below them would never be requested. The
126
+ // container is dropped here too, since this cache holds each fetched container by name.
127
+ async deleteBusiness(containerName) {
128
+ this._containers.delete(containerName);
129
+ await this._database.deleteContainer(containerName);
130
+ }
131
+ // Drops the party preferences message this container holds, and answers zero when there is none. It
132
+ // is the one delete no tombstone asked for. A cart written before anybody signed in is handed over to
133
+ // the container named for the membership, and the copy left behind would be read by the next anonymous
134
+ // visitor and the next customer to sign in - see Model.businessSyncIn. Nothing else in a container is
135
+ // a caller's to remove: it all came off the wire, and anything the server has not said is gone stays.
79
136
  async deletePartyPreferences(containerName) {
80
137
  const partyPreferences = await this.readPartyPreferences(containerName);
81
138
  if (partyPreferences === null)
82
139
  return 0;
83
140
  return this.write(containerName, {
84
141
  blobs: NO_BLOBS,
85
- deletedIds: [ProtoMap.partyPreferencesToBlob(partyPreferences).id],
142
+ deletedIds: [ProtoMap.getMessageId(partyPreferences)],
86
143
  });
87
144
  }
88
- // The messages a sync in response carried, in a single batch - a pushed order and the party
89
- // preferences message it emptied belong in one write, so that this cache cannot come to hold the
90
- // order without the emptying. Tombstones included, the same as any other entity array read off
91
- // the wire: this is what a push's own messages are stored as once the server has echoed them back
92
- // with real update numbers, and it is also how anything else of this customer's that the response
93
- // carried lands here. Neither is checked against a payload type, unlike the two writers above -
94
- // those are for a caller handing in a message it was given, where storing an order as party
95
- // preferences would be a mistake worth catching, and this is for messages the response itself is
96
- // the authority on.
145
+ // The messages a sync in response carried, in one batch, so the cache cannot hold a pushed order
146
+ // without the party preferences message it emptied. Tombstones are included, as for any entity array
147
+ // off the wire. This stores a push's own messages once the server has echoed them with real update
148
+ // numbers. It checks no payload type, unlike writeOrder and writePartyPreferences: those take a
149
+ // message the caller was given, where an order stored as party preferences is a mistake worth
150
+ // catching, and here the response is the authority. It moves no cursor, since this client made the
151
+ // change and the next read brings the same messages back.
97
152
  async writeMessages(containerName, messages) {
98
153
  return this.write(containerName, ProtoMap.messagesToCacheWrite(messages));
99
154
  }
100
155
  async readAll(containerName) {
101
156
  return (await this.getContainer(containerName)).readAll();
102
157
  }
103
- // For the writers that store one thing and delete nothing - a credential or a single message,
104
- // none of which a tombstone ever arrives for on its own
158
+ // A message the caller holds rather than one a response carried, versioned from what this container
159
+ // holds of it. One this client amended has no update number, so it is stored at that of the copy it
160
+ // amends - see ProtoMap.messageToBlob
161
+ async writeOwnMessage(containerName, message, toBlob) {
162
+ const held = await (await this.getContainer(containerName)).read(ProtoMap.getMessageId(message));
163
+ return this.writeBlobs(containerName, [toBlob(message, held?.eTag ?? null)]);
164
+ }
165
+ // For writers that store one thing and delete nothing (a credential or a single message), since no
166
+ // tombstone arrives for either on its own
105
167
  async writeBlobs(containerName, blobs) {
106
168
  return this.write(containerName, { blobs: blobs, deletedIds: NO_DELETES });
107
169
  }
108
- // One transaction per call, which is what makes an interrupted write repeat itself rather than leave the cache part written.
109
- // The count the store answers with covers only what it stored: a delete is unconditional, so
110
- // there is nothing for it to compare and nothing for it to report. Counting the ids instead can
111
- // therefore call a page that deleted nothing a page that changed something, when a tombstone
112
- // names an entity this container never held - which is the harmless direction, since a caller
113
- // republishes once for nothing rather than going on showing an entity the server has said is gone.
170
+ // One transaction per call, so an interrupted write repeats instead of leaving the cache part written.
171
+ // The store's count covers only what it stored: a blob it already holds a copy of that is as new (a
172
+ // tombstone too) is not stored and not counted. A delete has nothing to compare, so the ids are
173
+ // counted instead. The only delete is a caller's own - see deletePartyPreferences.
114
174
  async write(containerName, cacheWrite) {
115
175
  const blobs = cacheWrite.blobs;
116
176
  const deletedIds = cacheWrite.deletedIds;
@@ -119,8 +179,28 @@ export default class SyncCache {
119
179
  const storedCount = await (await this.getContainer(containerName)).write(blobs, deletedIds);
120
180
  return storedCount + deletedIds.length;
121
181
  }
122
- // Held against their names, so each container is fetched once however often it is used. The promise is held rather than the container, so two calls arriving together share one fetch.
123
- // A caller that deletes a container from the store gives up this cache with it: nothing here can tell that what it holds is no longer what the store would hand out.
182
+ // A page of a read: what it brought and the cursors it moved, in one batch. The count is what the
183
+ // page's entities changed, so the cursors are subtracted. The store keeps a cursor only where it is
184
+ // greater, and each moved one is greater than the one read, so it stores the moved cursors or fewer,
185
+ // when another writer moved one first. That is why the count is clamped at zero.
186
+ async writeRead(containerName, cacheWrite, cursorKeys, raiseCursors) {
187
+ const cursors = await this.readCursors(containerName, cursorKeys);
188
+ const movedKeys = raiseCursors(cursors);
189
+ const blobs = [...cacheWrite.blobs, ...ProtoMap.cursorsToBlobs(cursors, movedKeys)];
190
+ const deletedIds = cacheWrite.deletedIds;
191
+ if (blobs.length === 0 && deletedIds.length === 0)
192
+ return 0;
193
+ const storedCount = await (await this.getContainer(containerName)).write(blobs, deletedIds);
194
+ return Math.max(0, storedCount - movedKeys.size) + deletedIds.length;
195
+ }
196
+ async readCursors(containerName, keys) {
197
+ const container = await this.getContainer(containerName);
198
+ const blobs = await Promise.all(keys.map((key) => container.read(Ids.getSyncCursorBlobId(key))));
199
+ return ProtoMap.cursorsFromBlobs(keys, blobs);
200
+ }
201
+ // Held by name, so each container is fetched once. The promise is held, so two calls arriving
202
+ // together share one fetch. Whoever deletes a container from the store must drop it here too, since
203
+ // nothing here can tell that it no longer matches the store.
124
204
  getContainer(containerName) {
125
205
  const held = this._containers.get(containerName);
126
206
  if (held !== undefined)
package/dist/SyncKeys.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Constants as proto_Constants } from "./generated/Constants";
2
- // Which update numbers a SyncState keeps, and what entity type counts under which key. Kept here
3
- // rather than in SyncState since these are facts about the protocol, not about counting.
4
- // A state refuses keys outside its set on purpose: Business arrives from both sync outs with
5
- // unrelated update numbers, and these must never mix.
6
- // The cursors a business sync in sends: the business itself, its attachments, its configurations,
7
- // its messages and its items. These are the five cursor fields of a BusinessSyncOutRequest.
2
+ // Which update numbers a SyncState keeps, and which entity type counts under which key. Kept here
3
+ // since these are facts about the protocol, not about counting.
4
+ // A state refuses keys outside its set because Business arrives from both sync outs with
5
+ // unrelated update numbers, which must never mix.
6
+ // The cursors a business sync in sends: the five cursor fields of a BusinessSyncOutRequest (the
7
+ // business, its attachments, configurations, messages and items).
8
8
  export const BUSINESS_SYNC_IN = Object.freeze([
9
9
  proto_Constants.BUSINESS_ENTITY_TYPE_ID,
10
10
  proto_Constants.ATTACHMENT_ENTITY_TYPE_ID,
@@ -12,8 +12,8 @@ export const BUSINESS_SYNC_IN = Object.freeze([
12
12
  proto_Constants.MESSAGE_ENTITY_TYPE_ID,
13
13
  proto_Constants.ITEM_ENTITY_TYPE_ID,
14
14
  ]);
15
- // The cursors a profile sync in sends - the three entity types Java keeps in a profile database.
16
- // BUSINESS also appears above, with an unrelated number in each; never mix the two.
15
+ // The cursors a profile sync in sends: the three entity types Java keeps in a profile database.
16
+ // BUSINESS also appears above with an unrelated number, so never mix the two.
17
17
  export const PROFILE_SYNC_IN = Object.freeze([
18
18
  proto_Constants.USER_ENTITY_TYPE_ID,
19
19
  proto_Constants.BUSINESS_ENTITY_TYPE_ID,
package/dist/SyncState.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { InvalidArgumentException } from "./Exceptions";
2
2
  import * as SyncKeys from "./SyncKeys";
3
- // How far one read has got, as an update number per entity type computed from what arrives and sent back on the next call, which is what makes a read incremental. One class for both directions - only the keys differ.
4
- // Held in memory by whoever is reading and stored nowhere, so a new session asks from zero and is sent what it already holds: that costs bandwidth alone, since the store keeps a blob only when its eTag is the greater. "In" and "out" name the direction relative to this store, the opposite way round to the calls of the wire.
3
+ // How far one read has got: an update number per entity type, computed from what arrives and sent back on the next call, which makes a read incremental. One class for both directions; only the keys differ.
4
+ // Held in memory. SyncCache persists it, one blob per entity type, so a new session asks only for what changed. A container with none asks from zero and is sent what it already holds, which costs bandwidth only, since the store keeps a blob only when its eTag is greater. "In" and "out" are relative to this store, the opposite of the wire's calls.
5
5
  export default class SyncState {
6
- // The keys this state accepts, which is the whole difference between a business state and a profile one. Anything else is refused, because a Business arrives from both calls with an unrelated update number in each.
6
+ // The keys this state accepts, which is the whole difference between a business state and a profile one. Others are refused (see SyncKeys).
7
7
  constructor(_keys) {
8
8
  this._keys = _keys;
9
9
  this._updateNumbers = new Map();
@@ -12,8 +12,8 @@ export default class SyncState {
12
12
  getUpdateNumber(entityTypeId) {
13
13
  return this._updateNumbers.get(this.checkKey(entityTypeId)) ?? 0n;
14
14
  }
15
- // Raises the cursor for this entity type (tombstones included - skipping one would refetch it for ever) and reports whether it moved.
16
- // Numbers only ever rise; a lower one is ignored as stale or redelivered.
15
+ // Raises the cursor for this entity type and reports whether it moved. Tombstones count too, since skipping one would refetch it for ever.
16
+ // A lower number is ignored as stale or redelivered.
17
17
  raiseUpdateNumber(entityTypeId, updateNumber) {
18
18
  const key = this.checkKey(entityTypeId);
19
19
  if (updateNumber <= (this._updateNumbers.get(key) ?? 0n))
package/dist/Tax.d.ts CHANGED
@@ -1,10 +1,18 @@
1
- import { TaxType as proto_TaxType } from "./generated/TaxType";
1
+ import { TaxType } from "./TaxType";
2
2
  export default class Tax {
3
3
  private readonly _type;
4
4
  private readonly _rate;
5
- static fromScaledValues(type: proto_TaxType, scaledRate: number): Tax;
6
- constructor(_type: proto_TaxType, _rate: number);
7
- get type(): proto_TaxType;
5
+ static fromScaledValues(countryCode: number, taxCode: number, scaledRate: number): Tax;
6
+ constructor(_type: TaxType, _rate: number);
7
+ get type(): TaxType;
8
8
  get countryCode(): number;
9
+ get taxCode(): number;
9
10
  get rate(): number;
10
11
  }
12
+ export declare class TaxAmount {
13
+ private readonly _type;
14
+ private readonly _amount;
15
+ constructor(_type: TaxType, _amount: number);
16
+ get type(): TaxType;
17
+ get amount(): number;
18
+ }
package/dist/Tax.js CHANGED
@@ -1,13 +1,18 @@
1
+ import { countryCodeOf, createTaxType, taxCodeOf } from "./TaxType";
1
2
  import { taxRateFromScaledValue } from "./Constants";
2
- // A tax and its rate, applying to a product or a shopping-list line. Rate is exposed as a
3
- // percentage (18 for 18%), though the wire carries it scaled by Constants.TAX_RATE_FACTOR.
4
- // Deliberately doesn't compute tax on an amount - only a line item knows the amount, and does
5
- // that itself.
3
+ // A tax and its rate, on a product or a shopping-list line. The rate is a percentage (18 for 18%),
4
+ // though the wire carries it scaled by Constants.TAX_RATE_FACTOR.
5
+ //
6
+ // Taxes are included in the prices a customer sees: a product's in its package prices, the
7
+ // delivery charge's in the charge. A shopping list item stores its price without taxes, so
8
+ // CartEditor takes them out on the way in and ShoppingListItem.unitPriceWithTaxes puts them back
9
+ // for display. A Tax holds no amount. Only a line item knows one.
6
10
  export default class Tax {
7
- // Only ProtoMap calls this: it is the one place that sees a rate still multiplied by
8
- // Constants.TAX_RATE_FACTOR.
9
- static fromScaledValues(type, scaledRate) {
10
- return new Tax(type, taxRateFromScaledValue(scaledRate));
11
+ // For ProtoMap, which sees a rate still multiplied by Constants.TAX_RATE_FACTOR. The wire carries
12
+ // a tax as its raw code, which means something only within a country (that of the business the
13
+ // tax is on), so the type is made here from the two, as Java TaxType.createId does.
14
+ static fromScaledValues(countryCode, taxCode, scaledRate) {
15
+ return new Tax(createTaxType(countryCode, taxCode), taxRateFromScaledValue(scaledRate));
11
16
  }
12
17
  constructor(_type, _rate) {
13
18
  this._type = _type;
@@ -16,15 +21,34 @@ export default class Tax {
16
21
  get type() {
17
22
  return this._type;
18
23
  }
19
- // The country this tax belongs to, ISO 3166-1 numeric. A TaxType value is not a raw Java tax
20
- // code - it is Java TaxType.createId(country, taxCode), the country in its high bits - so the
21
- // country comes back out of the value itself rather than having to be carried alongside it.
22
- // Zero for TaxType.UNSPECIFIED_TAX_TYPE, no country's code being zero.
24
+ // The country this tax belongs to, ISO 3166-1 numeric. A TaxType value is Java
25
+ // TaxType.createId(country, taxCode), with the country in its high bits, so the country comes
26
+ // out of the value itself. Zero for TaxType.UNSPECIFIED_TAX_TYPE, as no country's code is zero.
23
27
  get countryCode() {
24
- return this._type >> 8;
28
+ return countryCodeOf(this._type);
29
+ }
30
+ // The raw tax code, which is all the wire carries of a tax: the low eight bits of the type
31
+ get taxCode() {
32
+ return taxCodeOf(this._type);
25
33
  }
26
34
  // A percentage: 18 means 18%
27
35
  get rate() {
28
36
  return this._rate;
29
37
  }
30
38
  }
39
+ // How much one type of tax comes to across a shopping list: the line a bill shows for it, "GST
40
+ // 5.25". Several taxes of one type, on one item or on many, are added into one. Always included in
41
+ // the list's total, never an extra on top.
42
+ export class TaxAmount {
43
+ constructor(_type, _amount) {
44
+ this._type = _type;
45
+ this._amount = _amount;
46
+ }
47
+ get type() {
48
+ return this._type;
49
+ }
50
+ // Zero is possible (a list of free items has taxes and owes none), and a bill shows no line for it
51
+ get amount() {
52
+ return this._amount;
53
+ }
54
+ }
@@ -0,0 +1,12 @@
1
+ import Tax from "./Tax";
2
+ export default class TaxHelper {
3
+ static scaledRatesOf(taxes: ReadonlyArray<Tax>): ReadonlyArray<number>;
4
+ static calculateTaxAmount(taxBase: bigint, taxRate: number): bigint;
5
+ static calculateTaxes(taxBase: bigint, taxRates: ReadonlyArray<number>): Array<bigint>;
6
+ static calculateTotalTaxRate(taxRates: ReadonlyArray<number>): number;
7
+ static addTaxes(taxBase: bigint, taxRates: ReadonlyArray<number>): bigint;
8
+ static removeTaxes(amountWithTaxes: bigint, taxRates: ReadonlyArray<number>): bigint;
9
+ private static divideAndRound;
10
+ private static abs;
11
+ private static readonly TAX_RATE_FACTOR;
12
+ }
@@ -0,0 +1,85 @@
1
+ import { Constants as proto_Constants } from "./generated/Constants";
2
+ import { taxRateToScaledValue } from "./Constants";
3
+ // The arithmetic of taxes, ported from Java com.merabills.repository.entities.helpers.TaxHelper to
4
+ // give the same answer to the unit. The only place in this library that works out a tax, adds it to
5
+ // an amount or takes it out. Not on the public surface (see index.ts): a UI reads unscaled taxes
6
+ // off a ShoppingListItem, ShoppingList or CartLine.
7
+ //
8
+ // Unlike the rest of this library it works on scaled integers, as Java does: an amount is a bigint
9
+ // multiplied by Constants.AMOUNT_FACTOR, a rate a number multiplied by Constants.TAX_RATE_FACTOR
10
+ // (5% is 500). Floating point would round some taxes the other way. Amounts are bigint because one
11
+ // can reach 2^44, and multiplying by a rate overflows 2^53.
12
+ // A tax is the tax base times the rate, divided by TAX_RATE_FACTOR and rounded half up (as
13
+ // Math.round). Each tax is rounded on its own, as each is shown and paid on its own.
14
+ class TaxHelper {
15
+ // The rates of these taxes as the arithmetic below takes them: scaled, in the same order. A rate
16
+ // not greater than zero is no tax. Java refuses one (validateTaxes) and drops one that arrives
17
+ // (getTaxCodesAndRates), so here it is zero, which keeps the taxes in step with their amounts.
18
+ static scaledRatesOf(taxes) {
19
+ return taxes.map((tax) => Number.isFinite(tax.rate) && tax.rate > 0 ? taxRateToScaledValue(tax.rate) : 0);
20
+ }
21
+ // One tax on a tax base (the amount before taxes)
22
+ static calculateTaxAmount(taxBase, taxRate) {
23
+ return TaxHelper.divideAndRound(taxBase * BigInt(taxRate), TaxHelper.TAX_RATE_FACTOR);
24
+ }
25
+ // Each of the taxes with these rates on a tax base, in the order of the rates
26
+ static calculateTaxes(taxBase, taxRates) {
27
+ return taxRates.map((taxRate) => TaxHelper.calculateTaxAmount(taxBase, taxRate));
28
+ }
29
+ // The sum of these rates (zero when there are none), scaled
30
+ static calculateTotalTaxRate(taxRates) {
31
+ let result = 0;
32
+ for (const taxRate of taxRates)
33
+ result += taxRate;
34
+ return result;
35
+ }
36
+ // The tax base plus the taxes with these rates on it. Taxes only apply to a tax base greater
37
+ // than zero.
38
+ static addTaxes(taxBase, taxRates) {
39
+ if (taxBase <= 0n)
40
+ return taxBase;
41
+ let result = taxBase;
42
+ for (const taxRate of taxRates)
43
+ result += TaxHelper.calculateTaxAmount(taxBase, taxRate);
44
+ return result;
45
+ }
46
+ // The inverse of addTaxes: the amount that gives this amount with taxes. Each tax is rounded on
47
+ // its own, so some amounts with taxes cannot be reached. Then this answers the amount for which
48
+ // addTaxes comes closest (the lower one on a tie). So removeTaxes(addTaxes(amount, rates),
49
+ // rates) is always amount.
50
+ static removeTaxes(amountWithTaxes, taxRates) {
51
+ if (amountWithTaxes <= 0n || taxRates.length === 0)
52
+ return amountWithTaxes;
53
+ // Ignoring rounding, the amount with taxes is the amount multiplied by (1 + the total rate), so
54
+ // dividing by that estimates the amount. Rounding moves each tax by at most half a unit and the
55
+ // estimate is rounded too, so the amount is within the number of taxes of the estimate (Java
56
+ // looks as far). Look at every amount that close.
57
+ const estimate = TaxHelper.divideAndRound(amountWithTaxes * TaxHelper.TAX_RATE_FACTOR, TaxHelper.TAX_RATE_FACTOR + BigInt(TaxHelper.calculateTotalTaxRate(taxRates)));
58
+ const reach = BigInt(taxRates.length);
59
+ const first = estimate > reach ? estimate - reach : 0n;
60
+ let result = estimate;
61
+ let smallestDifference = null;
62
+ for (let candidate = first; candidate <= estimate + reach; ++candidate) {
63
+ const difference = TaxHelper.abs(TaxHelper.addTaxes(candidate, taxRates) - amountWithTaxes);
64
+ // Strictly smaller, so of two equally close the lower amount (reached first) is kept
65
+ if (smallestDifference === null || difference < smallestDifference) {
66
+ smallestDifference = difference;
67
+ result = candidate;
68
+ }
69
+ }
70
+ return result;
71
+ }
72
+ // Divides by a positive denominator and rounds half up, as Math.round does. A bigint division
73
+ // truncates towards zero, so the floor is taken by hand.
74
+ static divideAndRound(numerator, denominator) {
75
+ const doubled = numerator * 2n + denominator;
76
+ const divisor = denominator * 2n;
77
+ const quotient = doubled / divisor;
78
+ return doubled % divisor !== 0n && doubled < 0n ? quotient - 1n : quotient;
79
+ }
80
+ static abs(value) {
81
+ return value < 0n ? -value : value;
82
+ }
83
+ }
84
+ TaxHelper.TAX_RATE_FACTOR = BigInt(proto_Constants.TAX_RATE_FACTOR);
85
+ export default TaxHelper;
@@ -0,0 +1,7 @@
1
+ export declare enum TaxType {
2
+ UNSPECIFIED_TAX_TYPE = 0,
3
+ GST = 91136
4
+ }
5
+ export declare function createTaxType(countryCode: number, taxCode: number): TaxType;
6
+ export declare function countryCodeOf(type: TaxType): number;
7
+ export declare function taxCodeOf(type: TaxType): number;
@@ -0,0 +1,31 @@
1
+ // The name of a tax, for a UI to show. Not part of the wire protocol: a Tax is sent as its raw tax
2
+ // code, unique only within a country, and ProtoMap combines it with the country of the business.
3
+ // The values are Java TaxType.createId(country, taxCode) = (country.getNumeric() << 8) | taxCode,
4
+ // so each is globally unique. countryCodeOf and taxCodeOf take one apart.
5
+ // Copied by hand from Java's TaxType and pinned there by TaxTypeIdsTest
6
+ // (repository/test/com/merabills/repository/entities/helpers), which fails when GST or the country
7
+ // shift changes or a type is added. A tax added there must be added here with the same value. This
8
+ // library's tests pin the same values (Values.test.ts).
9
+ export var TaxType;
10
+ (function (TaxType) {
11
+ // No country's numeric code is 0, so this cannot collide with a real type
12
+ TaxType[TaxType["UNSPECIFIED_TAX_TYPE"] = 0] = "UNSPECIFIED_TAX_TYPE";
13
+ // India (country code 356): Goods and Services Tax (tax code 0)
14
+ TaxType[TaxType["GST"] = 91136] = "GST";
15
+ })(TaxType || (TaxType = {}));
16
+ // Bits of a TaxType value the raw tax code takes, below the country - Java TaxType.COUNTRY_CODE_SHIFT
17
+ const TAX_CODE_BITS = 8;
18
+ const TAX_CODE_MASK = (1 << TAX_CODE_BITS) - 1;
19
+ // Java TaxType.createId: the country in the high bits, the raw tax code in the low eight. Not
20
+ // restricted to the TaxType members, since a country's tax this library has no name for is still a tax.
21
+ export function createTaxType(countryCode, taxCode) {
22
+ return ((countryCode << TAX_CODE_BITS) | taxCode);
23
+ }
24
+ // The country a type belongs to, ISO 3166-1 numeric; zero for UNSPECIFIED_TAX_TYPE
25
+ export function countryCodeOf(type) {
26
+ return type >> TAX_CODE_BITS;
27
+ }
28
+ // The raw tax code of a type: the low eight bits
29
+ export function taxCodeOf(type) {
30
+ return type & TAX_CODE_MASK;
31
+ }
@@ -1,7 +1,7 @@
1
1
  import Entity from "./Entity";
2
2
  // The customer: whose orders and cart the business sees. Named for the database it lives in
3
- // (profile, not business) - a signed-out storefront has no user but still shows its catalog.
4
- // Exactly the User message, with no derived state added.
3
+ // (profile, not business), since a signed-out storefront has no user but still shows its catalog.
4
+ // Holds the User message and no derived state.
5
5
  export default class UserProfile extends Entity {
6
6
  constructor(header, _firstName, _lastName, _mobilePhoneNumber, _addresses, _preferredLanguageCode) {
7
7
  super(header);
@@ -21,8 +21,8 @@ export default class UserProfile extends Entity {
21
21
  get mobilePhoneNumber() {
22
22
  return this._mobilePhoneNumber;
23
23
  }
24
- // The delivery addresses of this customer, and which of them is the default. This is also what
25
- // resolves Order.deliveryAddressId, which is an id into this list.
24
+ // The customer's delivery addresses, and which is the default. Also resolves
25
+ // Order.deliveryAddressId, an id into this list.
26
26
  get addresses() {
27
27
  return this._addresses;
28
28
  }