@plasius/economy 0.3.0 → 0.3.2

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.
package/dist/index.cjs CHANGED
@@ -33,6 +33,8 @@ __export(index_exports, {
33
33
  TOKEN_SUBUNITS_MIN: () => TOKEN_SUBUNITS_MIN,
34
34
  TOKEN_SUBUNITS_PER_TOKEN: () => TOKEN_SUBUNITS_PER_TOKEN,
35
35
  advanceJournalChainHead: () => advanceJournalChainHead,
36
+ applyPaidLotLifecycleEvent: () => applyPaidLotLifecycleEvent,
37
+ applyPurchaseIntentTransition: () => applyPurchaseIntentTransition,
36
38
  applySourceLotMovement: () => applySourceLotMovement,
37
39
  applyTransactionToProjection: () => applyTransactionToProjection,
38
40
  applyWalletBalanceDelta: () => applyWalletBalanceDelta,
@@ -52,11 +54,19 @@ __export(index_exports, {
52
54
  assertJournalChainHead: () => assertJournalChainHead,
53
55
  assertNonNegativeAccounts: () => assertNonNegativeAccounts,
54
56
  assertOpenPurchaseIntent: () => assertOpenPurchaseIntent,
57
+ assertPaidLotLifecycle: () => assertPaidLotLifecycle,
58
+ assertPaidLotLifecycleEvent: () => assertPaidLotLifecycleEvent,
59
+ assertPaidLotPurchaseProvenance: () => assertPaidLotPurchaseProvenance,
60
+ assertPurchaseCapReservation: () => assertPurchaseCapReservation,
61
+ assertPurchaseCapReservationTransition: () => assertPurchaseCapReservationTransition,
55
62
  assertPurchaseIntentBinding: () => assertPurchaseIntentBinding,
63
+ assertPurchaseIntentLifecycle: () => assertPurchaseIntentLifecycle,
64
+ assertPurchaseIntentTransition: () => assertPurchaseIntentTransition,
56
65
  assertPurchaseLimitPolicy: () => assertPurchaseLimitPolicy,
57
66
  assertReversalAvailable: () => assertReversalAvailable,
58
67
  assertRewardConversion: () => assertRewardConversion,
59
68
  assertRewardRate: () => assertRewardRate,
69
+ assertRollingPurchaseCapState: () => assertRollingPurchaseCapState,
60
70
  assertScopedPersistedIdempotencyResult: () => assertScopedPersistedIdempotencyResult,
61
71
  assertSourceLot: () => assertSourceLot,
62
72
  assertSourceLotMovement: () => assertSourceLotMovement,
@@ -82,13 +92,18 @@ __export(index_exports, {
82
92
  assertWalletPortfolioSummary: () => assertWalletPortfolioSummary,
83
93
  availableSourceLotAmount: () => availableSourceLotAmount,
84
94
  boostGameplayAllocation: () => boostGameplayAllocation,
95
+ calculateRollingPurchaseCapUsage: () => calculateRollingPurchaseCapUsage,
85
96
  canUseSourceLot: () => canUseSourceLot,
86
97
  canonicalTransactionPayload: () => canonicalTransactionPayload,
87
98
  convertRewardPayout: () => convertRewardPayout,
99
+ createEarlyBackerRetentionFromPaidLot: () => createEarlyBackerRetentionFromPaidLot,
88
100
  createGameplayAllocation: () => createGameplayAllocation,
89
101
  createInitialSourceLotSnapshot: () => createInitialSourceLotSnapshot,
102
+ createPaidLotLifecycle: () => createPaidLotLifecycle,
90
103
  createProjectionSnapshot: () => createProjectionSnapshot,
104
+ createPurchaseIntentLifecycle: () => createPurchaseIntentLifecycle,
91
105
  createReversalTransaction: () => createReversalTransaction,
106
+ createRollingPurchaseCapState: () => createRollingPurchaseCapState,
92
107
  createWalletBalanceSummary: () => createWalletBalanceSummary,
93
108
  createWalletPortfolioLifetime: () => createWalletPortfolioLifetime,
94
109
  createWalletPortfolioSummary: () => createWalletPortfolioSummary,
@@ -99,16 +114,22 @@ __export(index_exports, {
99
114
  evaluateEarlyBackerBySettlementV2: () => evaluateEarlyBackerBySettlementV2,
100
115
  isWholeTokenAmount: () => isWholeTokenAmount,
101
116
  mapBitLabsConversionState: () => mapBitLabsConversionState,
117
+ parseGbpMinorUnits: () => parseGbpMinorUnits,
102
118
  parseIsoTimestamp: () => parseIsoTimestamp,
103
119
  parseTokenSubunits: () => parseTokenSubunits,
104
120
  rebuildBalanceProjection: () => rebuildBalanceProjection,
105
121
  reclaimGameplayAllocation: () => reclaimGameplayAllocation,
122
+ reducePaidLotLifecycleEvents: () => reducePaidLotLifecycleEvents,
123
+ reducePurchaseIntentTransitions: () => reducePurchaseIntentTransitions,
124
+ reserveRollingPurchaseCaps: () => reserveRollingPurchaseCaps,
106
125
  selectSourceLots: () => selectSourceLots,
126
+ serializeGbpMinorUnits: () => serializeGbpMinorUnits,
107
127
  serializeTokenSubunits: () => serializeTokenSubunits,
108
128
  sortStringRecord: () => sortStringRecord,
109
129
  subunitsToWholeTokens: () => subunitsToWholeTokens,
110
130
  sumTokenSubunits: () => sumTokenSubunits,
111
131
  toEconomyErrorEnvelope: () => toEconomyErrorEnvelope,
132
+ transitionRollingPurchaseCapReservation: () => transitionRollingPurchaseCapReservation,
112
133
  wholeTokensToSubunits: () => wholeTokensToSubunits
113
134
  });
114
135
  module.exports = __toCommonJS(index_exports);
@@ -196,6 +217,14 @@ function sumTokenSubunits(values) {
196
217
  return total;
197
218
  }
198
219
 
220
+ // src/canonical-order.ts
221
+ function compareUnicodeCodeUnits(left, right) {
222
+ if (left === right) {
223
+ return 0;
224
+ }
225
+ return left < right ? -1 : 1;
226
+ }
227
+
199
228
  // src/contracts.ts
200
229
  var ECONOMY_CONTRACT_VERSION = "1";
201
230
  var IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
@@ -232,7 +261,9 @@ function parseIsoTimestamp(value) {
232
261
  }
233
262
  function sortStringRecord(value) {
234
263
  return Object.fromEntries(
235
- Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
264
+ Object.entries(value).sort(
265
+ ([left], [right]) => compareUnicodeCodeUnits(left, right)
266
+ )
236
267
  );
237
268
  }
238
269
 
@@ -1162,7 +1193,9 @@ function assertEconomicJournalTransaction(transaction) {
1162
1193
  }
1163
1194
  function canonicalTransactionPayload(transaction) {
1164
1195
  assertBalancedTransaction(transaction);
1165
- const postings = [...transaction.postings].sort((left, right) => left.postingId.localeCompare(right.postingId)).map((posting) => ({
1196
+ const postings = [...transaction.postings].sort(
1197
+ (left, right) => compareUnicodeCodeUnits(left.postingId, right.postingId)
1198
+ ).map((posting) => ({
1166
1199
  schemaVersion: posting.schemaVersion,
1167
1200
  postingId: posting.postingId,
1168
1201
  transactionId: posting.transactionId,
@@ -1695,6 +1728,420 @@ function assertDistinctAdjustmentApproval(request) {
1695
1728
  }
1696
1729
  }
1697
1730
 
1731
+ // src/paid-lots.ts
1732
+ var ZERO_ARITHMETIC = Object.freeze({
1733
+ retainedDelta: serializeTokenSubunits(0n),
1734
+ heldDelta: serializeTokenSubunits(0n),
1735
+ reversedDelta: serializeTokenSubunits(0n),
1736
+ refundedDelta: serializeTokenSubunits(0n),
1737
+ chargebackDelta: serializeTokenSubunits(0n),
1738
+ oneTimeReversalDelta: serializeTokenSubunits(0n)
1739
+ });
1740
+ var EVENT_TYPES = [
1741
+ "refund",
1742
+ "dispute-hold",
1743
+ "dispute-won",
1744
+ "dispute-lost",
1745
+ "chargeback",
1746
+ "reversal"
1747
+ ];
1748
+ var EVENT_PRECEDENCE = {
1749
+ "dispute-hold": 0,
1750
+ "dispute-won": 1,
1751
+ "dispute-lost": 1,
1752
+ refund: 2,
1753
+ chargeback: 3,
1754
+ reversal: 4
1755
+ };
1756
+ function assertPaidLotPurchaseProvenance(provenance) {
1757
+ economyAssert(
1758
+ provenance.schemaVersion === ECONOMY_CONTRACT_VERSION,
1759
+ "INVALID_CONTRACT",
1760
+ "Unsupported paid-lot provenance contract version"
1761
+ );
1762
+ assertEconomyIdentifier(provenance.purchaseId, "purchaseId");
1763
+ assertEconomyIdentifier(provenance.catalogVersion, "catalogVersion");
1764
+ parseIsoTimestamp(provenance.purchasedAt);
1765
+ }
1766
+ function assertPaidLotLifecycleEvent(event) {
1767
+ economyAssert(
1768
+ event.schemaVersion === ECONOMY_CONTRACT_VERSION,
1769
+ "INVALID_CONTRACT",
1770
+ "Unsupported paid-lot lifecycle event contract version"
1771
+ );
1772
+ assertEconomyIdentifier(event.eventId, "eventId");
1773
+ assertEconomyIdentifier(event.lotId, "lotId");
1774
+ economyAssert(
1775
+ EVENT_TYPES.includes(event.eventType),
1776
+ "INVALID_CONTRACT",
1777
+ "Paid-lot lifecycle event type is unsupported"
1778
+ );
1779
+ economyAssert(
1780
+ parseTokenSubunits(event.amount) > 0n,
1781
+ "INVALID_AMOUNT",
1782
+ "Paid-lot lifecycle event amount must be positive"
1783
+ );
1784
+ parseIsoTimestamp(event.occurredAt);
1785
+ }
1786
+ function sameEvent(left, right) {
1787
+ return left.schemaVersion === right.schemaVersion && left.eventId === right.eventId && left.lotId === right.lotId && left.eventType === right.eventType && left.amount === right.amount && left.occurredAt === right.occurredAt;
1788
+ }
1789
+ function compareEvents(left, right) {
1790
+ const timeDifference = parseIsoTimestamp(left.occurredAt) - parseIsoTimestamp(right.occurredAt);
1791
+ if (timeDifference !== 0) {
1792
+ return timeDifference;
1793
+ }
1794
+ const precedenceDifference = EVENT_PRECEDENCE[left.eventType] - EVENT_PRECEDENCE[right.eventType];
1795
+ return precedenceDifference === 0 ? compareUnicodeCodeUnits(left.eventId, right.eventId) : precedenceDifference;
1796
+ }
1797
+ function deriveStatus(retained, held, reversed, chargeback) {
1798
+ if (held > 0n) {
1799
+ return "disputed";
1800
+ }
1801
+ if (chargeback > 0n) {
1802
+ return "chargeback-lost";
1803
+ }
1804
+ if (retained === 0n) {
1805
+ return "fully-reversed";
1806
+ }
1807
+ return reversed > 0n ? "partially-reversed" : "clear";
1808
+ }
1809
+ function immutableFactsFromLifecycle(lifecycle) {
1810
+ return {
1811
+ schemaVersion: lifecycle.schemaVersion,
1812
+ lotId: lifecycle.lotId,
1813
+ payerAccountId: lifecycle.payerAccountId,
1814
+ receivingHouseholdId: lifecycle.receivingHouseholdId,
1815
+ purchaseId: lifecycle.purchaseId,
1816
+ catalogVersion: lifecycle.catalogVersion,
1817
+ purchasedAt: lifecycle.purchasedAt,
1818
+ settledAt: lifecycle.settledAt,
1819
+ creditedAt: lifecycle.creditedAt,
1820
+ originalAmount: lifecycle.originalAmount
1821
+ };
1822
+ }
1823
+ function assertImmutableFacts(facts) {
1824
+ economyAssert(
1825
+ facts.schemaVersion === ECONOMY_CONTRACT_VERSION,
1826
+ "INVALID_CONTRACT",
1827
+ "Unsupported paid-lot lifecycle contract version"
1828
+ );
1829
+ assertEconomyIdentifier(facts.lotId, "lotId");
1830
+ assertEconomyIdentifier(facts.payerAccountId, "payerAccountId");
1831
+ assertEconomyIdentifier(
1832
+ facts.receivingHouseholdId,
1833
+ "receivingHouseholdId"
1834
+ );
1835
+ assertEconomyIdentifier(facts.purchaseId, "purchaseId");
1836
+ assertEconomyIdentifier(facts.catalogVersion, "catalogVersion");
1837
+ const purchasedAt = parseIsoTimestamp(facts.purchasedAt);
1838
+ const settledAt = parseIsoTimestamp(facts.settledAt);
1839
+ const creditedAt = parseIsoTimestamp(facts.creditedAt);
1840
+ economyAssert(
1841
+ purchasedAt <= settledAt && settledAt <= creditedAt,
1842
+ "INVALID_TIME_WINDOW",
1843
+ "Paid-lot timestamps must follow purchase, settlement, then credit order"
1844
+ );
1845
+ economyAssert(
1846
+ parseTokenSubunits(facts.originalAmount) > 0n,
1847
+ "INVALID_AMOUNT",
1848
+ "Paid-lot original amount must be positive"
1849
+ );
1850
+ }
1851
+ function projectPaidLot(facts, receipts) {
1852
+ assertImmutableFacts(facts);
1853
+ const eventIds = /* @__PURE__ */ new Set();
1854
+ let reversalReceipts = 0;
1855
+ for (const receipt of receipts) {
1856
+ economyAssert(
1857
+ receipt.schemaVersion === ECONOMY_CONTRACT_VERSION,
1858
+ "INVALID_CONTRACT",
1859
+ "Unsupported paid-lot receipt contract version"
1860
+ );
1861
+ assertPaidLotLifecycleEvent(receipt.event);
1862
+ economyAssert(
1863
+ receipt.event.lotId === facts.lotId && !eventIds.has(receipt.event.eventId),
1864
+ "DUPLICATE_IDENTIFIER",
1865
+ "Paid-lot receipts must be unique and belong to the lifecycle lot"
1866
+ );
1867
+ eventIds.add(receipt.event.eventId);
1868
+ if (receipt.event.eventType === "reversal") {
1869
+ reversalReceipts += 1;
1870
+ }
1871
+ }
1872
+ economyAssert(
1873
+ reversalReceipts <= 1,
1874
+ "REVERSAL_ALREADY_EXISTS",
1875
+ "Paid-lot lifecycle permits only one distinct reversal event"
1876
+ );
1877
+ let retained = parseTokenSubunits(facts.originalAmount);
1878
+ let held = 0n;
1879
+ let reversed = 0n;
1880
+ let refunded = 0n;
1881
+ let chargeback = 0n;
1882
+ let oneTimeReversal = 0n;
1883
+ const effectiveEventIds = [];
1884
+ for (const receipt of [...receipts].sort(
1885
+ (left, right) => compareEvents(left.event, right.event)
1886
+ )) {
1887
+ const event = receipt.event;
1888
+ economyAssert(
1889
+ parseIsoTimestamp(event.occurredAt) >= parseIsoTimestamp(facts.creditedAt),
1890
+ "INVALID_TIME_WINDOW",
1891
+ "Paid-lot lifecycle event cannot precede credit"
1892
+ );
1893
+ const amount = parseTokenSubunits(event.amount);
1894
+ if (event.eventType === "dispute-hold") {
1895
+ economyAssert(
1896
+ amount <= retained - held,
1897
+ "INSUFFICIENT_ELIGIBLE_LOTS",
1898
+ "Dispute hold exceeds unheld retained paid-lot basis"
1899
+ );
1900
+ held += amount;
1901
+ } else if (event.eventType === "dispute-won") {
1902
+ economyAssert(
1903
+ amount <= held,
1904
+ "INVALID_AMOUNT",
1905
+ "Dispute-win release exceeds held paid-lot basis"
1906
+ );
1907
+ held -= amount;
1908
+ } else if (event.eventType === "dispute-lost") {
1909
+ economyAssert(
1910
+ amount <= held,
1911
+ "INVALID_AMOUNT",
1912
+ "Dispute-loss amount exceeds held paid-lot basis"
1913
+ );
1914
+ held -= amount;
1915
+ retained -= amount;
1916
+ reversed += amount;
1917
+ chargeback += amount;
1918
+ } else {
1919
+ economyAssert(
1920
+ amount <= retained - held,
1921
+ "INSUFFICIENT_ELIGIBLE_LOTS",
1922
+ "Paid-lot reversal exceeds unheld retained basis"
1923
+ );
1924
+ if (event.eventType === "reversal") {
1925
+ oneTimeReversal += amount;
1926
+ } else if (event.eventType === "refund") {
1927
+ refunded += amount;
1928
+ } else {
1929
+ chargeback += amount;
1930
+ }
1931
+ retained -= amount;
1932
+ reversed += amount;
1933
+ }
1934
+ effectiveEventIds.push(event.eventId);
1935
+ }
1936
+ return {
1937
+ retained,
1938
+ held,
1939
+ reversed,
1940
+ refunded,
1941
+ chargeback,
1942
+ oneTimeReversal,
1943
+ status: deriveStatus(retained, held, reversed, chargeback),
1944
+ effectiveEventIds
1945
+ };
1946
+ }
1947
+ function projectionMatchesLifecycle(projection, lifecycle) {
1948
+ return serializeTokenSubunits(projection.retained) === lifecycle.retainedAmount && serializeTokenSubunits(projection.held) === lifecycle.heldAmount && serializeTokenSubunits(projection.reversed) === lifecycle.reversedAmount && serializeTokenSubunits(projection.refunded) === lifecycle.refundedAmount && serializeTokenSubunits(projection.chargeback) === lifecycle.chargebackAmount && serializeTokenSubunits(projection.oneTimeReversal) === lifecycle.oneTimeReversalAmount && projection.status === lifecycle.status && projection.effectiveEventIds.length === lifecycle.effectiveEventIds.length && projection.effectiveEventIds.every(
1949
+ (eventId, index) => eventId === lifecycle.effectiveEventIds[index]
1950
+ );
1951
+ }
1952
+ function assertPaidLotLifecycle(lifecycle) {
1953
+ economyAssert(
1954
+ Number.isSafeInteger(lifecycle.version) && lifecycle.version === lifecycle.receipts.length + 1,
1955
+ "INVALID_CONTRACT",
1956
+ "Paid-lot lifecycle version must match its receipt count"
1957
+ );
1958
+ const projection = projectPaidLot(
1959
+ immutableFactsFromLifecycle(lifecycle),
1960
+ lifecycle.receipts
1961
+ );
1962
+ economyAssert(
1963
+ projectionMatchesLifecycle(projection, lifecycle),
1964
+ "INVALID_CONTRACT",
1965
+ "Paid-lot lifecycle does not match its immutable event rebuild"
1966
+ );
1967
+ }
1968
+ function createPaidLotLifecycle(lot, provenance) {
1969
+ assertSourceLot(lot);
1970
+ assertPaidLotPurchaseProvenance(provenance);
1971
+ economyAssert(
1972
+ lot.payerAccountId !== void 0 && lot.householdId !== void 0,
1973
+ "INVALID_CONTRACT",
1974
+ "Paid-lot lifecycle requires payer and receiving-household provenance"
1975
+ );
1976
+ economyAssert(
1977
+ lot.refundState === "none" && parseTokenSubunits(lot.heldAmount) === 0n && parseTokenSubunits(lot.reversedAmount) === 0n,
1978
+ "INVALID_CONTRACT",
1979
+ "Paid-lot lifecycle must be created before any refund or dispute movement"
1980
+ );
1981
+ economyAssert(
1982
+ parseIsoTimestamp(provenance.purchasedAt) <= parseIsoTimestamp(lot.settledAt),
1983
+ "INVALID_TIME_WINDOW",
1984
+ "Paid purchase time cannot follow settlement"
1985
+ );
1986
+ const facts = {
1987
+ schemaVersion: ECONOMY_CONTRACT_VERSION,
1988
+ lotId: lot.lotId,
1989
+ payerAccountId: lot.payerAccountId,
1990
+ receivingHouseholdId: lot.householdId,
1991
+ purchaseId: provenance.purchaseId,
1992
+ catalogVersion: provenance.catalogVersion,
1993
+ purchasedAt: provenance.purchasedAt,
1994
+ settledAt: lot.settledAt,
1995
+ creditedAt: lot.creditedAt,
1996
+ originalAmount: lot.originalAmount
1997
+ };
1998
+ const projection = projectPaidLot(facts, []);
1999
+ const lifecycle = {
2000
+ ...facts,
2001
+ retainedAmount: serializeTokenSubunits(projection.retained),
2002
+ heldAmount: serializeTokenSubunits(projection.held),
2003
+ reversedAmount: serializeTokenSubunits(projection.reversed),
2004
+ refundedAmount: serializeTokenSubunits(projection.refunded),
2005
+ chargebackAmount: serializeTokenSubunits(projection.chargeback),
2006
+ oneTimeReversalAmount: serializeTokenSubunits(
2007
+ projection.oneTimeReversal
2008
+ ),
2009
+ status: projection.status,
2010
+ version: 1,
2011
+ receipts: [],
2012
+ effectiveEventIds: []
2013
+ };
2014
+ assertPaidLotLifecycle(lifecycle);
2015
+ return lifecycle;
2016
+ }
2017
+ function applyPaidLotLifecycleEvent(lifecycle, event, expectedVersion) {
2018
+ assertPaidLotLifecycle(lifecycle);
2019
+ assertPaidLotLifecycleEvent(event);
2020
+ economyAssert(
2021
+ event.lotId === lifecycle.lotId,
2022
+ "INVALID_CONTRACT",
2023
+ "Paid-lot event belongs to another source lot"
2024
+ );
2025
+ const replay = lifecycle.receipts.find(
2026
+ (receipt) => receipt.event.eventId === event.eventId
2027
+ );
2028
+ if (replay !== void 0) {
2029
+ economyAssert(
2030
+ sameEvent(replay.event, event),
2031
+ "DUPLICATE_IDENTIFIER",
2032
+ "Paid-lot event ID was reused with different facts"
2033
+ );
2034
+ return {
2035
+ lifecycle,
2036
+ applied: false,
2037
+ stateChanged: false,
2038
+ arithmetic: ZERO_ARITHMETIC
2039
+ };
2040
+ }
2041
+ economyAssert(
2042
+ expectedVersion === lifecycle.version,
2043
+ "INVALID_CONTRACT",
2044
+ "Paid-lot lifecycle event has a stale expected version"
2045
+ );
2046
+ const receipts = [
2047
+ ...lifecycle.receipts,
2048
+ { schemaVersion: ECONOMY_CONTRACT_VERSION, event }
2049
+ ];
2050
+ const projection = projectPaidLot(
2051
+ immutableFactsFromLifecycle(lifecycle),
2052
+ receipts
2053
+ );
2054
+ const before = {
2055
+ retained: parseTokenSubunits(lifecycle.retainedAmount),
2056
+ held: parseTokenSubunits(lifecycle.heldAmount),
2057
+ reversed: parseTokenSubunits(lifecycle.reversedAmount),
2058
+ refunded: parseTokenSubunits(lifecycle.refundedAmount),
2059
+ chargeback: parseTokenSubunits(lifecycle.chargebackAmount),
2060
+ oneTimeReversal: parseTokenSubunits(lifecycle.oneTimeReversalAmount)
2061
+ };
2062
+ const next = {
2063
+ ...lifecycle,
2064
+ retainedAmount: serializeTokenSubunits(projection.retained),
2065
+ heldAmount: serializeTokenSubunits(projection.held),
2066
+ reversedAmount: serializeTokenSubunits(projection.reversed),
2067
+ refundedAmount: serializeTokenSubunits(projection.refunded),
2068
+ chargebackAmount: serializeTokenSubunits(projection.chargeback),
2069
+ oneTimeReversalAmount: serializeTokenSubunits(
2070
+ projection.oneTimeReversal
2071
+ ),
2072
+ status: projection.status,
2073
+ version: lifecycle.version + 1,
2074
+ receipts,
2075
+ effectiveEventIds: projection.effectiveEventIds
2076
+ };
2077
+ assertPaidLotLifecycle(next);
2078
+ const arithmetic = {
2079
+ retainedDelta: serializeTokenSubunits(
2080
+ projection.retained - before.retained
2081
+ ),
2082
+ heldDelta: serializeTokenSubunits(projection.held - before.held),
2083
+ reversedDelta: serializeTokenSubunits(
2084
+ projection.reversed - before.reversed
2085
+ ),
2086
+ refundedDelta: serializeTokenSubunits(
2087
+ projection.refunded - before.refunded
2088
+ ),
2089
+ chargebackDelta: serializeTokenSubunits(
2090
+ projection.chargeback - before.chargeback
2091
+ ),
2092
+ oneTimeReversalDelta: serializeTokenSubunits(
2093
+ projection.oneTimeReversal - before.oneTimeReversal
2094
+ )
2095
+ };
2096
+ return {
2097
+ lifecycle: next,
2098
+ applied: true,
2099
+ stateChanged: Object.values(arithmetic).some((delta) => delta !== "0"),
2100
+ arithmetic
2101
+ };
2102
+ }
2103
+ function reducePaidLotLifecycleEvents(initial, events) {
2104
+ assertPaidLotLifecycle(initial);
2105
+ const unique = /* @__PURE__ */ new Map();
2106
+ for (const event of events) {
2107
+ assertPaidLotLifecycleEvent(event);
2108
+ const existing = unique.get(event.eventId);
2109
+ if (existing !== void 0) {
2110
+ economyAssert(
2111
+ sameEvent(existing, event),
2112
+ "DUPLICATE_IDENTIFIER",
2113
+ "Paid-lot event ID was reused with different facts"
2114
+ );
2115
+ } else {
2116
+ unique.set(event.eventId, event);
2117
+ }
2118
+ }
2119
+ let lifecycle = initial;
2120
+ for (const event of [...unique.values()].sort(compareEvents)) {
2121
+ lifecycle = applyPaidLotLifecycleEvent(
2122
+ lifecycle,
2123
+ event,
2124
+ lifecycle.version
2125
+ ).lifecycle;
2126
+ }
2127
+ return lifecycle;
2128
+ }
2129
+ function createEarlyBackerRetentionFromPaidLot(lifecycle) {
2130
+ assertPaidLotLifecycle(lifecycle);
2131
+ return {
2132
+ schemaVersion: ECONOMY_CONTRACT_VERSION,
2133
+ lotId: lifecycle.lotId,
2134
+ payerAccountId: lifecycle.payerAccountId,
2135
+ receivingHouseholdId: lifecycle.receivingHouseholdId,
2136
+ purchaseId: lifecycle.purchaseId,
2137
+ catalogVersion: lifecycle.catalogVersion,
2138
+ purchasedAt: lifecycle.purchasedAt,
2139
+ settledAt: lifecycle.settledAt,
2140
+ creditedAt: lifecycle.creditedAt,
2141
+ retainedAmount: lifecycle.retainedAmount
2142
+ };
2143
+ }
2144
+
1698
2145
  // src/ports/persistence.ts
1699
2146
  function assertScopedPersistedIdempotencyResult(result) {
1700
2147
  economyAssert(
@@ -2762,6 +3209,899 @@ function assertWalletActivityPageForPortfolio(page, scope) {
2762
3209
  );
2763
3210
  }
2764
3211
 
3212
+ // src/purchase-caps.ts
3213
+ var MAX_SIGNED_BIGINT = 2n ** 63n - 1n;
3214
+ var CANONICAL_NON_NEGATIVE_INTEGER = /^(?:0|[1-9][0-9]*)$/u;
3215
+ function parseGbpMinorUnits(value) {
3216
+ economyAssert(
3217
+ typeof value === "string" && CANONICAL_NON_NEGATIVE_INTEGER.test(value),
3218
+ "INVALID_AMOUNT",
3219
+ "GBP minor units must be a canonical non-negative integer string"
3220
+ );
3221
+ try {
3222
+ const amount = BigInt(value);
3223
+ economyAssert(
3224
+ amount <= MAX_SIGNED_BIGINT,
3225
+ "AMOUNT_OUT_OF_RANGE",
3226
+ "GBP minor units are outside the signed 64-bit range"
3227
+ );
3228
+ return amount;
3229
+ } catch (error) {
3230
+ if (error instanceof EconomyError) {
3231
+ throw error;
3232
+ }
3233
+ throw new EconomyError("INVALID_AMOUNT", "GBP minor units are invalid");
3234
+ }
3235
+ }
3236
+ function serializeGbpMinorUnits(amount) {
3237
+ economyAssert(
3238
+ amount >= 0n && amount <= MAX_SIGNED_BIGINT,
3239
+ "AMOUNT_OUT_OF_RANGE",
3240
+ "GBP minor units are outside the signed 64-bit range"
3241
+ );
3242
+ return amount.toString(10);
3243
+ }
3244
+ var RESERVATION_STATUSES = [
3245
+ "reserved",
3246
+ "settled",
3247
+ "released",
3248
+ "expired"
3249
+ ];
3250
+ var TRANSITION_PRECEDENCE = {
3251
+ settle: 0,
3252
+ release: 1,
3253
+ expire: 2
3254
+ };
3255
+ function assertPurchaseCapReservationTransition(transition) {
3256
+ economyAssert(
3257
+ transition.schemaVersion === ECONOMY_CONTRACT_VERSION,
3258
+ "INVALID_CONTRACT",
3259
+ "Unsupported purchase-cap transition contract version"
3260
+ );
3261
+ assertEconomyIdentifier(transition.transitionId, "transitionId");
3262
+ assertEconomyIdentifier(transition.reservationId, "reservationId");
3263
+ economyAssert(
3264
+ ["settle", "release", "expire"].includes(transition.transitionType),
3265
+ "INVALID_CONTRACT",
3266
+ "Purchase-cap transition type is unsupported"
3267
+ );
3268
+ parseIsoTimestamp(transition.occurredAt);
3269
+ }
3270
+ function sameTransition(left, right) {
3271
+ return left.schemaVersion === right.schemaVersion && left.transitionId === right.transitionId && left.reservationId === right.reservationId && left.transitionType === right.transitionType && left.occurredAt === right.occurredAt;
3272
+ }
3273
+ function compareTransitions(left, right) {
3274
+ const timeDifference = parseIsoTimestamp(left.occurredAt) - parseIsoTimestamp(right.occurredAt);
3275
+ if (timeDifference !== 0) {
3276
+ return timeDifference;
3277
+ }
3278
+ const precedenceDifference = TRANSITION_PRECEDENCE[left.transitionType] - TRANSITION_PRECEDENCE[right.transitionType];
3279
+ return precedenceDifference === 0 ? compareUnicodeCodeUnits(left.transitionId, right.transitionId) : precedenceDifference;
3280
+ }
3281
+ function assertBinding(binding) {
3282
+ economyAssert(
3283
+ binding.schemaVersion === ECONOMY_CONTRACT_VERSION,
3284
+ "INVALID_CONTRACT",
3285
+ "Unsupported purchase-cap reservation contract version"
3286
+ );
3287
+ assertEconomyIdentifier(binding.reservationId, "reservationId");
3288
+ assertEconomyIdentifier(binding.payerAccountId, "payerAccountId");
3289
+ assertEconomyIdentifier(binding.householdId, "householdId");
3290
+ economyAssert(
3291
+ parseGbpMinorUnits(binding.priceMinorUnits) > 0n,
3292
+ "INVALID_AMOUNT",
3293
+ "Purchase-cap reservation amount must be positive"
3294
+ );
3295
+ economyAssert(
3296
+ parseIsoTimestamp(binding.expiresAt) > parseIsoTimestamp(binding.reservedAt),
3297
+ "INVALID_TIME_WINDOW",
3298
+ "Purchase-cap reservation expiry must follow its creation"
3299
+ );
3300
+ }
3301
+ function bindingFromReservation(reservation) {
3302
+ return {
3303
+ schemaVersion: reservation.schemaVersion,
3304
+ reservationId: reservation.reservationId,
3305
+ payerAccountId: reservation.payerAccountId,
3306
+ householdId: reservation.householdId,
3307
+ priceMinorUnits: reservation.priceMinorUnits,
3308
+ reservedAt: reservation.reservedAt,
3309
+ expiresAt: reservation.expiresAt
3310
+ };
3311
+ }
3312
+ function projectReservation(binding, receipts) {
3313
+ assertBinding(binding);
3314
+ const reservedAt = parseIsoTimestamp(binding.reservedAt);
3315
+ const expiresAt = parseIsoTimestamp(binding.expiresAt);
3316
+ const transitionIds = /* @__PURE__ */ new Set();
3317
+ for (const receipt of receipts) {
3318
+ economyAssert(
3319
+ receipt.schemaVersion === ECONOMY_CONTRACT_VERSION,
3320
+ "INVALID_CONTRACT",
3321
+ "Unsupported purchase-cap transition receipt version"
3322
+ );
3323
+ assertPurchaseCapReservationTransition(receipt.transition);
3324
+ economyAssert(
3325
+ receipt.transition.reservationId === binding.reservationId && !transitionIds.has(receipt.transition.transitionId),
3326
+ "DUPLICATE_IDENTIFIER",
3327
+ "Purchase-cap receipts must be unique and belong to the reservation"
3328
+ );
3329
+ transitionIds.add(receipt.transition.transitionId);
3330
+ }
3331
+ let status = "reserved";
3332
+ let finalTransitionId;
3333
+ let finalizedAt;
3334
+ for (const receipt of [...receipts].sort(
3335
+ (left, right) => compareTransitions(left.transition, right.transition)
3336
+ )) {
3337
+ const transition = receipt.transition;
3338
+ const occurredAt = parseIsoTimestamp(transition.occurredAt);
3339
+ economyAssert(
3340
+ occurredAt >= reservedAt,
3341
+ "INVALID_TIME_WINDOW",
3342
+ "Purchase-cap transition cannot precede reservation"
3343
+ );
3344
+ if (transition.transitionType === "settle") {
3345
+ economyAssert(
3346
+ occurredAt < expiresAt,
3347
+ "INVALID_TIME_WINDOW",
3348
+ "Purchase-cap settlement must use payment time before expiry"
3349
+ );
3350
+ } else if (transition.transitionType === "release") {
3351
+ economyAssert(
3352
+ occurredAt < expiresAt,
3353
+ "INVALID_TIME_WINDOW",
3354
+ "Purchase-cap release must precede reservation expiry"
3355
+ );
3356
+ } else {
3357
+ economyAssert(
3358
+ occurredAt >= expiresAt,
3359
+ "INVALID_TIME_WINDOW",
3360
+ "Purchase-cap reservation cannot expire before its deadline"
3361
+ );
3362
+ }
3363
+ if (status === "reserved") {
3364
+ status = transition.transitionType === "settle" ? "settled" : transition.transitionType === "release" ? "released" : "expired";
3365
+ finalTransitionId = transition.transitionId;
3366
+ finalizedAt = transition.occurredAt;
3367
+ }
3368
+ }
3369
+ return {
3370
+ ...binding,
3371
+ status,
3372
+ ...finalTransitionId === void 0 ? {} : { finalTransitionId, finalizedAt },
3373
+ transitionReceipts: receipts
3374
+ };
3375
+ }
3376
+ function sameReservationFacts(left, right) {
3377
+ return left.schemaVersion === right.schemaVersion && left.reservationId === right.reservationId && left.payerAccountId === right.payerAccountId && left.householdId === right.householdId && left.priceMinorUnits === right.priceMinorUnits && left.status === right.status && left.reservedAt === right.reservedAt && left.expiresAt === right.expiresAt && left.finalTransitionId === right.finalTransitionId && left.finalizedAt === right.finalizedAt && left.transitionReceipts.length === right.transitionReceipts.length && left.transitionReceipts.every((receipt, index) => {
3378
+ const other = right.transitionReceipts[index];
3379
+ return other !== void 0 && receipt.schemaVersion === other.schemaVersion && sameTransition(receipt.transition, other.transition);
3380
+ });
3381
+ }
3382
+ function sameReservationBinding(reservation, proposed) {
3383
+ return reservation.schemaVersion === proposed.schemaVersion && reservation.reservationId === proposed.reservationId && reservation.payerAccountId === proposed.payerAccountId && reservation.householdId === proposed.householdId && reservation.priceMinorUnits === proposed.priceMinorUnits && reservation.reservedAt === proposed.reservedAt && reservation.expiresAt === proposed.expiresAt;
3384
+ }
3385
+ function assertPurchaseCapReservation(reservation) {
3386
+ economyAssert(
3387
+ RESERVATION_STATUSES.includes(reservation.status),
3388
+ "INVALID_CONTRACT",
3389
+ "Purchase-cap reservation status is unsupported"
3390
+ );
3391
+ const projected = projectReservation(
3392
+ bindingFromReservation(reservation),
3393
+ reservation.transitionReceipts
3394
+ );
3395
+ economyAssert(
3396
+ sameReservationFacts(projected, reservation),
3397
+ "INVALID_CONTRACT",
3398
+ "Purchase-cap reservation does not match its immutable event rebuild"
3399
+ );
3400
+ }
3401
+ function assertRollingPurchaseCapState(state) {
3402
+ economyAssert(
3403
+ state.schemaVersion === ECONOMY_CONTRACT_VERSION,
3404
+ "INVALID_CONTRACT",
3405
+ "Unsupported rolling purchase-cap contract version"
3406
+ );
3407
+ economyAssert(
3408
+ state.scopeType === "payer" || state.scopeType === "household",
3409
+ "INVALID_CONTRACT",
3410
+ "Purchase-cap scope type is unsupported"
3411
+ );
3412
+ assertEconomyIdentifier(state.scopeId, "scopeId");
3413
+ const reservationIds = /* @__PURE__ */ new Set();
3414
+ const transitionIds = /* @__PURE__ */ new Set();
3415
+ let receiptCount = 0;
3416
+ for (const reservation of state.reservations) {
3417
+ assertPurchaseCapReservation(reservation);
3418
+ economyAssert(
3419
+ !reservationIds.has(reservation.reservationId),
3420
+ "DUPLICATE_IDENTIFIER",
3421
+ "Purchase-cap state cannot repeat a reservation"
3422
+ );
3423
+ reservationIds.add(reservation.reservationId);
3424
+ economyAssert(
3425
+ state.scopeType === "payer" ? reservation.payerAccountId === state.scopeId : reservation.householdId === state.scopeId,
3426
+ "INVALID_CONTRACT",
3427
+ "Purchase-cap reservation belongs to another aggregate scope"
3428
+ );
3429
+ for (const receipt of reservation.transitionReceipts) {
3430
+ economyAssert(
3431
+ !transitionIds.has(receipt.transition.transitionId),
3432
+ "DUPLICATE_IDENTIFIER",
3433
+ "Purchase-cap state cannot repeat a transition ID"
3434
+ );
3435
+ transitionIds.add(receipt.transition.transitionId);
3436
+ receiptCount += 1;
3437
+ }
3438
+ }
3439
+ economyAssert(
3440
+ Number.isSafeInteger(state.version) && state.version === 1 + state.reservations.length + receiptCount,
3441
+ "INVALID_CONTRACT",
3442
+ "Purchase-cap state version must match reservation and receipt counts"
3443
+ );
3444
+ }
3445
+ function assertAggregatePair(payerState, householdState, payerAccountId, householdId) {
3446
+ assertRollingPurchaseCapState(payerState);
3447
+ assertRollingPurchaseCapState(householdState);
3448
+ economyAssert(
3449
+ payerState.scopeType === "payer" && payerState.scopeId === payerAccountId && householdState.scopeType === "household" && householdState.scopeId === householdId,
3450
+ "INVALID_CONTRACT",
3451
+ "Purchase-cap aggregate pair does not match payer and household"
3452
+ );
3453
+ }
3454
+ function findMirroredReservation(payerState, householdState, reservationId) {
3455
+ const payerReservation = payerState.reservations.find(
3456
+ (reservation) => reservation.reservationId === reservationId
3457
+ );
3458
+ const householdReservation = householdState.reservations.find(
3459
+ (reservation) => reservation.reservationId === reservationId
3460
+ );
3461
+ economyAssert(
3462
+ payerReservation === void 0 === (householdReservation === void 0),
3463
+ "INVALID_CONTRACT",
3464
+ "Purchase-cap reservation must exist in both aggregate scopes atomically"
3465
+ );
3466
+ if (payerReservation === void 0 || householdReservation === void 0) {
3467
+ return void 0;
3468
+ }
3469
+ economyAssert(
3470
+ sameReservationFacts(payerReservation, householdReservation),
3471
+ "INVALID_CONTRACT",
3472
+ "Payer and household purchase-cap reservations have diverged"
3473
+ );
3474
+ return payerReservation;
3475
+ }
3476
+ function windowStart(checkedAt, rollingDays) {
3477
+ return checkedAt - rollingDays * 24 * 60 * 60 * 1e3;
3478
+ }
3479
+ function calculateRollingPurchaseCapUsage(state, checkedAt, policy) {
3480
+ assertRollingPurchaseCapState(state);
3481
+ assertPurchaseLimitPolicy(policy);
3482
+ const checkedAtValue = parseIsoTimestamp(checkedAt);
3483
+ const start = windowStart(checkedAtValue, policy.rollingDays);
3484
+ let total = 0n;
3485
+ for (const reservation of state.reservations) {
3486
+ if (reservation.status === "reserved" && parseIsoTimestamp(reservation.reservedAt) <= checkedAtValue && checkedAtValue < parseIsoTimestamp(reservation.expiresAt)) {
3487
+ total += parseGbpMinorUnits(reservation.priceMinorUnits);
3488
+ } else if (reservation.status === "settled" && reservation.finalizedAt !== void 0) {
3489
+ const settledAt = parseIsoTimestamp(reservation.finalizedAt);
3490
+ if (settledAt >= start && settledAt <= checkedAtValue) {
3491
+ total += parseGbpMinorUnits(reservation.priceMinorUnits);
3492
+ }
3493
+ }
3494
+ economyAssert(
3495
+ total <= MAX_SIGNED_BIGINT,
3496
+ "AMOUNT_OUT_OF_RANGE",
3497
+ "Rolling purchase-cap usage exceeds signed 64-bit range"
3498
+ );
3499
+ }
3500
+ return serializeGbpMinorUnits(total);
3501
+ }
3502
+ function createRollingPurchaseCapState(scopeType, scopeId) {
3503
+ economyAssert(
3504
+ scopeType === "payer" || scopeType === "household",
3505
+ "INVALID_CONTRACT",
3506
+ "Purchase-cap scope type is unsupported"
3507
+ );
3508
+ assertEconomyIdentifier(scopeId, "scopeId");
3509
+ return {
3510
+ schemaVersion: ECONOMY_CONTRACT_VERSION,
3511
+ scopeType,
3512
+ scopeId,
3513
+ version: 1,
3514
+ reservations: []
3515
+ };
3516
+ }
3517
+ function sortedWithReservation(state, reservation) {
3518
+ return [...state.reservations, reservation].sort(
3519
+ (left, right) => compareUnicodeCodeUnits(left.reservationId, right.reservationId)
3520
+ );
3521
+ }
3522
+ function resultWithUsage(payerState, householdState, reservation, applied, stateChanged, checkedAt, policy) {
3523
+ return {
3524
+ payerState,
3525
+ householdState,
3526
+ reservation,
3527
+ applied,
3528
+ stateChanged,
3529
+ payerUsageMinorUnits: calculateRollingPurchaseCapUsage(
3530
+ payerState,
3531
+ checkedAt,
3532
+ policy
3533
+ ),
3534
+ householdUsageMinorUnits: calculateRollingPurchaseCapUsage(
3535
+ householdState,
3536
+ checkedAt,
3537
+ policy
3538
+ )
3539
+ };
3540
+ }
3541
+ function reserveRollingPurchaseCaps(payerState, householdState, command, policy) {
3542
+ economyAssert(
3543
+ command.schemaVersion === ECONOMY_CONTRACT_VERSION,
3544
+ "INVALID_CONTRACT",
3545
+ "Unsupported purchase-cap reservation command version"
3546
+ );
3547
+ const binding = {
3548
+ schemaVersion: ECONOMY_CONTRACT_VERSION,
3549
+ reservationId: command.reservationId,
3550
+ payerAccountId: command.payerAccountId,
3551
+ householdId: command.householdId,
3552
+ priceMinorUnits: command.priceMinorUnits,
3553
+ reservedAt: command.reservedAt,
3554
+ expiresAt: command.expiresAt
3555
+ };
3556
+ assertBinding(binding);
3557
+ assertAggregatePair(
3558
+ payerState,
3559
+ householdState,
3560
+ command.payerAccountId,
3561
+ command.householdId
3562
+ );
3563
+ assertPurchaseLimitPolicy(policy);
3564
+ const proposed = projectReservation(binding, []);
3565
+ const replay = findMirroredReservation(
3566
+ payerState,
3567
+ householdState,
3568
+ command.reservationId
3569
+ );
3570
+ if (replay !== void 0) {
3571
+ economyAssert(
3572
+ sameReservationBinding(replay, proposed),
3573
+ "DUPLICATE_IDENTIFIER",
3574
+ "Purchase-cap reservation ID was reused with different facts"
3575
+ );
3576
+ return resultWithUsage(
3577
+ payerState,
3578
+ householdState,
3579
+ replay,
3580
+ false,
3581
+ false,
3582
+ replay.finalizedAt ?? command.reservedAt,
3583
+ policy
3584
+ );
3585
+ }
3586
+ economyAssert(
3587
+ command.expectedPayerVersion === payerState.version && command.expectedHouseholdVersion === householdState.version,
3588
+ "INVALID_CONTRACT",
3589
+ "Purchase-cap reservation has a stale aggregate version"
3590
+ );
3591
+ const amount = parseGbpMinorUnits(command.priceMinorUnits);
3592
+ economyAssert(
3593
+ amount <= parseGbpMinorUnits(policy.maxOrderPriceMinorUnits),
3594
+ "INSUFFICIENT_BALANCE",
3595
+ "Purchase amount exceeds the per-order ceiling"
3596
+ );
3597
+ const payerUsage = parseGbpMinorUnits(
3598
+ calculateRollingPurchaseCapUsage(payerState, command.reservedAt, policy)
3599
+ );
3600
+ const householdUsage = parseGbpMinorUnits(
3601
+ calculateRollingPurchaseCapUsage(
3602
+ householdState,
3603
+ command.reservedAt,
3604
+ policy
3605
+ )
3606
+ );
3607
+ economyAssert(
3608
+ payerUsage + amount <= parseGbpMinorUnits(policy.rollingPayerPriceMinorUnits),
3609
+ "INSUFFICIENT_BALANCE",
3610
+ "Purchase amount exceeds the rolling payer ceiling"
3611
+ );
3612
+ economyAssert(
3613
+ householdUsage + amount <= parseGbpMinorUnits(policy.rollingHouseholdPriceMinorUnits),
3614
+ "INSUFFICIENT_BALANCE",
3615
+ "Purchase amount exceeds the rolling household ceiling"
3616
+ );
3617
+ const nextPayer = {
3618
+ ...payerState,
3619
+ version: payerState.version + 1,
3620
+ reservations: sortedWithReservation(payerState, proposed)
3621
+ };
3622
+ const nextHousehold = {
3623
+ ...householdState,
3624
+ version: householdState.version + 1,
3625
+ reservations: sortedWithReservation(householdState, proposed)
3626
+ };
3627
+ return resultWithUsage(
3628
+ nextPayer,
3629
+ nextHousehold,
3630
+ proposed,
3631
+ true,
3632
+ true,
3633
+ command.reservedAt,
3634
+ policy
3635
+ );
3636
+ }
3637
+ function replaceReservation(state, replacement) {
3638
+ return {
3639
+ ...state,
3640
+ version: state.version + 1,
3641
+ reservations: state.reservations.map(
3642
+ (reservation) => reservation.reservationId === replacement.reservationId ? replacement : reservation
3643
+ )
3644
+ };
3645
+ }
3646
+ function findTransitionById(state, transitionId) {
3647
+ for (const reservation of state.reservations) {
3648
+ const receipt = reservation.transitionReceipts.find(
3649
+ (candidate) => candidate.transition.transitionId === transitionId
3650
+ );
3651
+ if (receipt !== void 0) {
3652
+ return { reservationId: reservation.reservationId, transition: receipt.transition };
3653
+ }
3654
+ }
3655
+ return void 0;
3656
+ }
3657
+ function transitionRollingPurchaseCapReservation(payerState, householdState, transition, expectedPayerVersion, expectedHouseholdVersion, policy) {
3658
+ assertPurchaseCapReservationTransition(transition);
3659
+ assertRollingPurchaseCapState(payerState);
3660
+ assertRollingPurchaseCapState(householdState);
3661
+ const current = findMirroredReservation(
3662
+ payerState,
3663
+ householdState,
3664
+ transition.reservationId
3665
+ );
3666
+ economyAssert(
3667
+ current !== void 0,
3668
+ "INVALID_CONTRACT",
3669
+ "Purchase-cap reservation does not exist in both aggregate scopes"
3670
+ );
3671
+ assertAggregatePair(
3672
+ payerState,
3673
+ householdState,
3674
+ current.payerAccountId,
3675
+ current.householdId
3676
+ );
3677
+ assertPurchaseLimitPolicy(policy);
3678
+ const payerExisting = findTransitionById(
3679
+ payerState,
3680
+ transition.transitionId
3681
+ );
3682
+ const householdExisting = findTransitionById(
3683
+ householdState,
3684
+ transition.transitionId
3685
+ );
3686
+ economyAssert(
3687
+ payerExisting === void 0 === (householdExisting === void 0),
3688
+ "INVALID_CONTRACT",
3689
+ "Purchase-cap transition must exist in both scopes atomically"
3690
+ );
3691
+ if (payerExisting !== void 0 && householdExisting !== void 0) {
3692
+ economyAssert(
3693
+ payerExisting.reservationId === transition.reservationId && householdExisting.reservationId === transition.reservationId && sameTransition(payerExisting.transition, transition) && sameTransition(householdExisting.transition, transition),
3694
+ "DUPLICATE_IDENTIFIER",
3695
+ "Purchase-cap transition ID was reused with different facts"
3696
+ );
3697
+ return resultWithUsage(
3698
+ payerState,
3699
+ householdState,
3700
+ current,
3701
+ false,
3702
+ false,
3703
+ transition.occurredAt,
3704
+ policy
3705
+ );
3706
+ }
3707
+ economyAssert(
3708
+ expectedPayerVersion === payerState.version && expectedHouseholdVersion === householdState.version,
3709
+ "INVALID_CONTRACT",
3710
+ "Purchase-cap transition has a stale aggregate version"
3711
+ );
3712
+ const replacement = projectReservation(bindingFromReservation(current), [
3713
+ ...current.transitionReceipts,
3714
+ { schemaVersion: ECONOMY_CONTRACT_VERSION, transition }
3715
+ ]);
3716
+ const stateChanged = replacement.status !== current.status || replacement.finalTransitionId !== current.finalTransitionId || replacement.finalizedAt !== current.finalizedAt;
3717
+ const nextPayer = replaceReservation(payerState, replacement);
3718
+ const nextHousehold = replaceReservation(householdState, replacement);
3719
+ return resultWithUsage(
3720
+ nextPayer,
3721
+ nextHousehold,
3722
+ replacement,
3723
+ true,
3724
+ stateChanged,
3725
+ transition.occurredAt,
3726
+ policy
3727
+ );
3728
+ }
3729
+
3730
+ // src/purchase-intents.ts
3731
+ var TRANSITION_TYPES = [
3732
+ "checkout-bound",
3733
+ "payment-observed",
3734
+ "credit-recorded",
3735
+ "expire",
3736
+ "cancel",
3737
+ "dispute-opened",
3738
+ "dispute-won",
3739
+ "dispute-lost"
3740
+ ];
3741
+ var TRANSITION_PRECEDENCE2 = {
3742
+ "checkout-bound": 0,
3743
+ "payment-observed": 1,
3744
+ "credit-recorded": 2,
3745
+ "dispute-opened": 3,
3746
+ "dispute-won": 4,
3747
+ "dispute-lost": 4,
3748
+ cancel: 5,
3749
+ expire: 6
3750
+ };
3751
+ function assertCheckoutHash(value) {
3752
+ economyAssert(
3753
+ /^sha256:[a-f0-9]{64}$/u.test(value),
3754
+ "INVALID_CONTRACT",
3755
+ "Checkout references must be stored as sanitized SHA-256 hashes"
3756
+ );
3757
+ }
3758
+ function assertPurchaseIntentTransition(transition) {
3759
+ economyAssert(
3760
+ transition.schemaVersion === ECONOMY_CONTRACT_VERSION,
3761
+ "INVALID_CONTRACT",
3762
+ "Unsupported purchase-intent transition contract version"
3763
+ );
3764
+ assertEconomyIdentifier(transition.transitionId, "transitionId");
3765
+ assertEconomyIdentifier(transition.intentId, "intentId");
3766
+ economyAssert(
3767
+ TRANSITION_TYPES.includes(transition.transitionType),
3768
+ "INVALID_CONTRACT",
3769
+ "Purchase-intent transition type is unsupported"
3770
+ );
3771
+ parseIsoTimestamp(transition.occurredAt);
3772
+ if (transition.providerCheckoutReferenceHash !== void 0) {
3773
+ assertCheckoutHash(transition.providerCheckoutReferenceHash);
3774
+ }
3775
+ const requiresCheckoutHash = transition.transitionType === "checkout-bound" || transition.transitionType === "payment-observed";
3776
+ economyAssert(
3777
+ requiresCheckoutHash === (transition.providerCheckoutReferenceHash !== void 0),
3778
+ "INVALID_CONTRACT",
3779
+ "Only checkout and payment transitions must carry a checkout binding"
3780
+ );
3781
+ }
3782
+ function sameTransition2(left, right) {
3783
+ return left.schemaVersion === right.schemaVersion && left.transitionId === right.transitionId && left.intentId === right.intentId && left.transitionType === right.transitionType && left.occurredAt === right.occurredAt && left.providerCheckoutReferenceHash === right.providerCheckoutReferenceHash;
3784
+ }
3785
+ function sameIntent(left, right) {
3786
+ return left.schemaVersion === right.schemaVersion && left.intentId === right.intentId && left.payerAccountId === right.payerAccountId && left.receivingHouseholdId === right.receivingHouseholdId && left.receivingWalletId === right.receivingWalletId && left.packId === right.packId && left.catalogVersion === right.catalogVersion && left.expectedCurrency === right.expectedCurrency && left.expectedPriceMinorUnits === right.expectedPriceMinorUnits && left.grantAmount === right.grantAmount && left.status === right.status && left.createdAt === right.createdAt && left.expiresAt === right.expiresAt && left.providerCheckoutReferenceHash === right.providerCheckoutReferenceHash;
3787
+ }
3788
+ function compareTransitions2(left, right) {
3789
+ const timeDifference = parseIsoTimestamp(left.occurredAt) - parseIsoTimestamp(right.occurredAt);
3790
+ if (timeDifference !== 0) {
3791
+ return timeDifference;
3792
+ }
3793
+ const precedenceDifference = TRANSITION_PRECEDENCE2[left.transitionType] - TRANSITION_PRECEDENCE2[right.transitionType];
3794
+ return precedenceDifference === 0 ? compareUnicodeCodeUnits(left.transitionId, right.transitionId) : precedenceDifference;
3795
+ }
3796
+ function assertMatchingCheckoutBinding(intent, checkoutHash) {
3797
+ economyAssert(
3798
+ intent.providerCheckoutReferenceHash === void 0 || intent.providerCheckoutReferenceHash === checkoutHash,
3799
+ "INVALID_CONTRACT",
3800
+ "Purchase-intent transition conflicts with its checkout binding"
3801
+ );
3802
+ }
3803
+ function projectPurchaseIntent(initialIntent, transitions, pack) {
3804
+ assertPurchaseIntentBinding(initialIntent, pack);
3805
+ economyAssert(
3806
+ initialIntent.status === "created" && initialIntent.providerCheckoutReferenceHash === void 0,
3807
+ "INVALID_CONTRACT",
3808
+ "Purchase-intent lifecycle requires immutable created intent facts"
3809
+ );
3810
+ const createdAt = parseIsoTimestamp(initialIntent.createdAt);
3811
+ const expiresAt = parseIsoTimestamp(initialIntent.expiresAt);
3812
+ let intent = initialIntent;
3813
+ let disputeDisposition = "none";
3814
+ let creditTransitionId;
3815
+ const effectiveTransitionIds = [];
3816
+ for (const transition of [...transitions].sort(compareTransitions2)) {
3817
+ assertPurchaseIntentTransition(transition);
3818
+ economyAssert(
3819
+ transition.intentId === initialIntent.intentId,
3820
+ "INVALID_CONTRACT",
3821
+ "Purchase-intent transition belongs to another intent"
3822
+ );
3823
+ const eventTime = parseIsoTimestamp(transition.occurredAt);
3824
+ economyAssert(
3825
+ eventTime >= createdAt,
3826
+ "INVALID_TIME_WINDOW",
3827
+ "Purchase-intent transition cannot precede intent creation"
3828
+ );
3829
+ let effective = false;
3830
+ if (transition.transitionType === "checkout-bound") {
3831
+ const checkoutHash = transition.providerCheckoutReferenceHash;
3832
+ assertMatchingCheckoutBinding(intent, checkoutHash);
3833
+ if (intent.status === "created") {
3834
+ economyAssert(
3835
+ eventTime < expiresAt,
3836
+ "INVALID_TIME_WINDOW",
3837
+ "Checkout cannot be bound after purchase-intent expiry"
3838
+ );
3839
+ intent = {
3840
+ ...intent,
3841
+ status: "checkout-created",
3842
+ providerCheckoutReferenceHash: checkoutHash
3843
+ };
3844
+ effective = true;
3845
+ } else {
3846
+ economyAssert(
3847
+ !["expired", "cancelled"].includes(intent.status),
3848
+ "INVALID_CONTRACT",
3849
+ "A terminal purchase intent cannot bind a checkout"
3850
+ );
3851
+ }
3852
+ } else if (transition.transitionType === "payment-observed") {
3853
+ const checkoutHash = transition.providerCheckoutReferenceHash;
3854
+ assertMatchingCheckoutBinding(intent, checkoutHash);
3855
+ economyAssert(
3856
+ eventTime < expiresAt,
3857
+ "INVALID_TIME_WINDOW",
3858
+ "Authoritative payment time must precede purchase-intent expiry"
3859
+ );
3860
+ if (intent.status === "created") {
3861
+ intent = {
3862
+ ...intent,
3863
+ status: "paid-unreconciled",
3864
+ providerCheckoutReferenceHash: checkoutHash
3865
+ };
3866
+ effective = true;
3867
+ } else if (intent.status === "checkout-created") {
3868
+ intent = { ...intent, status: "paid-unreconciled" };
3869
+ effective = true;
3870
+ } else {
3871
+ economyAssert(
3872
+ !["expired", "cancelled"].includes(intent.status),
3873
+ "INVALID_CONTRACT",
3874
+ "A terminal purchase intent cannot observe payment"
3875
+ );
3876
+ }
3877
+ } else if (transition.transitionType === "credit-recorded") {
3878
+ if (intent.status === "paid-unreconciled") {
3879
+ intent = { ...intent, status: "credited" };
3880
+ disputeDisposition = "none";
3881
+ creditTransitionId = transition.transitionId;
3882
+ effective = true;
3883
+ } else {
3884
+ economyAssert(
3885
+ intent.status === "credited" || intent.status === "disputed",
3886
+ "INVALID_CONTRACT",
3887
+ "Purchase intent cannot be credited before authoritative payment"
3888
+ );
3889
+ }
3890
+ } else if (transition.transitionType === "dispute-opened") {
3891
+ if (intent.status === "credited" && disputeDisposition === "none") {
3892
+ intent = { ...intent, status: "disputed" };
3893
+ disputeDisposition = "open";
3894
+ effective = true;
3895
+ } else {
3896
+ economyAssert(
3897
+ intent.status === "disputed" && disputeDisposition === "open",
3898
+ "INVALID_CONTRACT",
3899
+ "Dispute can open only once after purchase credit"
3900
+ );
3901
+ }
3902
+ } else if (transition.transitionType === "dispute-won") {
3903
+ if (intent.status === "disputed" && disputeDisposition === "open") {
3904
+ intent = { ...intent, status: "credited" };
3905
+ disputeDisposition = "won";
3906
+ effective = true;
3907
+ } else {
3908
+ economyAssert(
3909
+ intent.status === "credited" && disputeDisposition === "won",
3910
+ "INVALID_CONTRACT",
3911
+ "Dispute-win transition requires one open dispute"
3912
+ );
3913
+ }
3914
+ } else if (transition.transitionType === "dispute-lost") {
3915
+ if (intent.status === "disputed" && disputeDisposition === "open") {
3916
+ disputeDisposition = "lost";
3917
+ effective = true;
3918
+ } else {
3919
+ economyAssert(
3920
+ intent.status === "disputed" && disputeDisposition === "lost",
3921
+ "INVALID_CONTRACT",
3922
+ "Dispute-loss transition requires one open dispute"
3923
+ );
3924
+ }
3925
+ } else if (transition.transitionType === "cancel") {
3926
+ if (intent.status === "created" || intent.status === "checkout-created") {
3927
+ intent = { ...intent, status: "cancelled" };
3928
+ effective = true;
3929
+ }
3930
+ } else {
3931
+ economyAssert(
3932
+ eventTime >= expiresAt,
3933
+ "INVALID_TIME_WINDOW",
3934
+ "Expiry transition cannot precede purchase-intent expiry"
3935
+ );
3936
+ if (intent.status === "created" || intent.status === "checkout-created") {
3937
+ intent = { ...intent, status: "expired" };
3938
+ effective = true;
3939
+ }
3940
+ }
3941
+ if (effective) {
3942
+ effectiveTransitionIds.push(transition.transitionId);
3943
+ }
3944
+ }
3945
+ return {
3946
+ intent,
3947
+ disputeDisposition,
3948
+ effectiveTransitionIds,
3949
+ ...creditTransitionId === void 0 ? {} : { creditTransitionId }
3950
+ };
3951
+ }
3952
+ function assertPurchaseIntentLifecycle(lifecycle, pack) {
3953
+ economyAssert(
3954
+ lifecycle.schemaVersion === ECONOMY_CONTRACT_VERSION,
3955
+ "INVALID_CONTRACT",
3956
+ "Unsupported purchase-intent lifecycle contract version"
3957
+ );
3958
+ economyAssert(
3959
+ Number.isSafeInteger(lifecycle.version) && lifecycle.version === lifecycle.receipts.length + 1,
3960
+ "INVALID_CONTRACT",
3961
+ "Purchase-intent lifecycle version must match its receipt count"
3962
+ );
3963
+ const transitionIds = /* @__PURE__ */ new Set();
3964
+ for (const receipt of lifecycle.receipts) {
3965
+ economyAssert(
3966
+ receipt.schemaVersion === ECONOMY_CONTRACT_VERSION,
3967
+ "INVALID_CONTRACT",
3968
+ "Purchase-intent receipt is invalid"
3969
+ );
3970
+ assertPurchaseIntentTransition(receipt.transition);
3971
+ economyAssert(
3972
+ receipt.transition.intentId === lifecycle.initialIntent.intentId && !transitionIds.has(receipt.transition.transitionId),
3973
+ "DUPLICATE_IDENTIFIER",
3974
+ "Purchase-intent receipts must be unique and belong to the intent"
3975
+ );
3976
+ transitionIds.add(receipt.transition.transitionId);
3977
+ }
3978
+ const projection = projectPurchaseIntent(
3979
+ lifecycle.initialIntent,
3980
+ lifecycle.receipts.map((receipt) => receipt.transition),
3981
+ pack
3982
+ );
3983
+ economyAssert(
3984
+ sameIntent(projection.intent, lifecycle.intent) && lifecycle.creditRecorded === (projection.intent.status === "credited" || projection.intent.status === "disputed") && lifecycle.disputeDisposition === projection.disputeDisposition && lifecycle.effectiveTransitionIds.length === projection.effectiveTransitionIds.length && lifecycle.effectiveTransitionIds.every(
3985
+ (transitionId, index) => transitionId === projection.effectiveTransitionIds[index]
3986
+ ),
3987
+ "INVALID_CONTRACT",
3988
+ "Purchase-intent lifecycle does not match its immutable event rebuild"
3989
+ );
3990
+ }
3991
+ function createPurchaseIntentLifecycle(initialIntent, pack) {
3992
+ const projection = projectPurchaseIntent(initialIntent, [], pack);
3993
+ return {
3994
+ schemaVersion: ECONOMY_CONTRACT_VERSION,
3995
+ initialIntent,
3996
+ intent: projection.intent,
3997
+ version: 1,
3998
+ creditRecorded: false,
3999
+ disputeDisposition: projection.disputeDisposition,
4000
+ receipts: [],
4001
+ effectiveTransitionIds: []
4002
+ };
4003
+ }
4004
+ function sameProjection(lifecycle, projection) {
4005
+ return sameIntent(lifecycle.intent, projection.intent) && lifecycle.disputeDisposition === projection.disputeDisposition && lifecycle.effectiveTransitionIds.length === projection.effectiveTransitionIds.length && lifecycle.effectiveTransitionIds.every(
4006
+ (transitionId, index) => transitionId === projection.effectiveTransitionIds[index]
4007
+ );
4008
+ }
4009
+ function applyPurchaseIntentTransition(lifecycle, transition, expectedVersion, pack) {
4010
+ assertPurchaseIntentLifecycle(lifecycle, pack);
4011
+ assertPurchaseIntentTransition(transition);
4012
+ economyAssert(
4013
+ transition.intentId === lifecycle.initialIntent.intentId,
4014
+ "INVALID_CONTRACT",
4015
+ "Purchase-intent transition belongs to another intent"
4016
+ );
4017
+ const replay = lifecycle.receipts.find(
4018
+ (receipt) => receipt.transition.transitionId === transition.transitionId
4019
+ );
4020
+ if (replay !== void 0) {
4021
+ economyAssert(
4022
+ sameTransition2(replay.transition, transition),
4023
+ "DUPLICATE_IDENTIFIER",
4024
+ "Purchase-intent transition ID was reused with different facts"
4025
+ );
4026
+ return { lifecycle, recorded: false, stateChanged: false };
4027
+ }
4028
+ economyAssert(
4029
+ expectedVersion === lifecycle.version,
4030
+ "INVALID_CONTRACT",
4031
+ "Purchase-intent transition has a stale expected version"
4032
+ );
4033
+ const receipts = [
4034
+ ...lifecycle.receipts,
4035
+ { schemaVersion: ECONOMY_CONTRACT_VERSION, transition }
4036
+ ];
4037
+ const projection = projectPurchaseIntent(
4038
+ lifecycle.initialIntent,
4039
+ receipts.map((receipt) => receipt.transition),
4040
+ pack
4041
+ );
4042
+ const stateChanged = !sameProjection(lifecycle, projection);
4043
+ const nextLifecycle = {
4044
+ schemaVersion: ECONOMY_CONTRACT_VERSION,
4045
+ initialIntent: lifecycle.initialIntent,
4046
+ intent: projection.intent,
4047
+ version: lifecycle.version + 1,
4048
+ creditRecorded: projection.intent.status === "credited" || projection.intent.status === "disputed",
4049
+ disputeDisposition: projection.disputeDisposition,
4050
+ receipts,
4051
+ effectiveTransitionIds: projection.effectiveTransitionIds
4052
+ };
4053
+ assertPurchaseIntentLifecycle(nextLifecycle, pack);
4054
+ const producesCredit = projection.creditTransitionId === transition.transitionId && !lifecycle.creditRecorded && nextLifecycle.creditRecorded;
4055
+ const creditInstruction = producesCredit ? {
4056
+ schemaVersion: ECONOMY_CONTRACT_VERSION,
4057
+ intentId: nextLifecycle.intent.intentId,
4058
+ transitionId: transition.transitionId,
4059
+ idempotencyKey: nextLifecycle.intent.intentId
4060
+ } : void 0;
4061
+ return {
4062
+ lifecycle: nextLifecycle,
4063
+ recorded: true,
4064
+ stateChanged,
4065
+ ...creditInstruction === void 0 ? {} : { creditInstruction }
4066
+ };
4067
+ }
4068
+ function reducePurchaseIntentTransitions(initialIntent, transitions, pack) {
4069
+ const unique = /* @__PURE__ */ new Map();
4070
+ for (const transition of transitions) {
4071
+ assertPurchaseIntentTransition(transition);
4072
+ const existing = unique.get(transition.transitionId);
4073
+ if (existing !== void 0) {
4074
+ economyAssert(
4075
+ sameTransition2(existing, transition),
4076
+ "DUPLICATE_IDENTIFIER",
4077
+ "Purchase-intent transition ID was reused with different facts"
4078
+ );
4079
+ } else {
4080
+ unique.set(transition.transitionId, transition);
4081
+ }
4082
+ }
4083
+ let lifecycle = createPurchaseIntentLifecycle(initialIntent, pack);
4084
+ const creditInstructions = [];
4085
+ for (const transition of [...unique.values()].sort(compareTransitions2)) {
4086
+ const result = applyPurchaseIntentTransition(
4087
+ lifecycle,
4088
+ transition,
4089
+ lifecycle.version,
4090
+ pack
4091
+ );
4092
+ lifecycle = result.lifecycle;
4093
+ if (result.creditInstruction !== void 0) {
4094
+ creditInstructions.push(result.creditInstruction);
4095
+ }
4096
+ }
4097
+ economyAssert(
4098
+ creditInstructions.length <= 1,
4099
+ "DUPLICATE_TRANSACTION",
4100
+ "Purchase-intent reduction produced more than one credit"
4101
+ );
4102
+ return { lifecycle, creditInstructions };
4103
+ }
4104
+
2765
4105
  // src/recurrence.ts
2766
4106
  var BASELINE_MONTHLY_SUBSCRIPTION_PLAN = Object.freeze({
2767
4107
  schemaVersion: ECONOMY_CONTRACT_VERSION,
@@ -2900,6 +4240,8 @@ function assertSpendRequest(request) {
2900
4240
  TOKEN_SUBUNITS_MIN,
2901
4241
  TOKEN_SUBUNITS_PER_TOKEN,
2902
4242
  advanceJournalChainHead,
4243
+ applyPaidLotLifecycleEvent,
4244
+ applyPurchaseIntentTransition,
2903
4245
  applySourceLotMovement,
2904
4246
  applyTransactionToProjection,
2905
4247
  applyWalletBalanceDelta,
@@ -2919,11 +4261,19 @@ function assertSpendRequest(request) {
2919
4261
  assertJournalChainHead,
2920
4262
  assertNonNegativeAccounts,
2921
4263
  assertOpenPurchaseIntent,
4264
+ assertPaidLotLifecycle,
4265
+ assertPaidLotLifecycleEvent,
4266
+ assertPaidLotPurchaseProvenance,
4267
+ assertPurchaseCapReservation,
4268
+ assertPurchaseCapReservationTransition,
2922
4269
  assertPurchaseIntentBinding,
4270
+ assertPurchaseIntentLifecycle,
4271
+ assertPurchaseIntentTransition,
2923
4272
  assertPurchaseLimitPolicy,
2924
4273
  assertReversalAvailable,
2925
4274
  assertRewardConversion,
2926
4275
  assertRewardRate,
4276
+ assertRollingPurchaseCapState,
2927
4277
  assertScopedPersistedIdempotencyResult,
2928
4278
  assertSourceLot,
2929
4279
  assertSourceLotMovement,
@@ -2949,13 +4299,18 @@ function assertSpendRequest(request) {
2949
4299
  assertWalletPortfolioSummary,
2950
4300
  availableSourceLotAmount,
2951
4301
  boostGameplayAllocation,
4302
+ calculateRollingPurchaseCapUsage,
2952
4303
  canUseSourceLot,
2953
4304
  canonicalTransactionPayload,
2954
4305
  convertRewardPayout,
4306
+ createEarlyBackerRetentionFromPaidLot,
2955
4307
  createGameplayAllocation,
2956
4308
  createInitialSourceLotSnapshot,
4309
+ createPaidLotLifecycle,
2957
4310
  createProjectionSnapshot,
4311
+ createPurchaseIntentLifecycle,
2958
4312
  createReversalTransaction,
4313
+ createRollingPurchaseCapState,
2959
4314
  createWalletBalanceSummary,
2960
4315
  createWalletPortfolioLifetime,
2961
4316
  createWalletPortfolioSummary,
@@ -2966,16 +4321,22 @@ function assertSpendRequest(request) {
2966
4321
  evaluateEarlyBackerBySettlementV2,
2967
4322
  isWholeTokenAmount,
2968
4323
  mapBitLabsConversionState,
4324
+ parseGbpMinorUnits,
2969
4325
  parseIsoTimestamp,
2970
4326
  parseTokenSubunits,
2971
4327
  rebuildBalanceProjection,
2972
4328
  reclaimGameplayAllocation,
4329
+ reducePaidLotLifecycleEvents,
4330
+ reducePurchaseIntentTransitions,
4331
+ reserveRollingPurchaseCaps,
2973
4332
  selectSourceLots,
4333
+ serializeGbpMinorUnits,
2974
4334
  serializeTokenSubunits,
2975
4335
  sortStringRecord,
2976
4336
  subunitsToWholeTokens,
2977
4337
  sumTokenSubunits,
2978
4338
  toEconomyErrorEnvelope,
4339
+ transitionRollingPurchaseCapReservation,
2979
4340
  wholeTokensToSubunits
2980
4341
  });
2981
4342
  //# sourceMappingURL=index.cjs.map