@mpgd/platform 0.20.0 → 0.22.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.
- package/dist/commerce-conformance.d.ts +34 -51
- package/dist/commerce-conformance.js +72 -465
- package/dist/commerce-contract.d.ts +31 -2
- package/dist/commerce-wallet.d.ts +324 -0
- package/dist/commerce-wallet.js +2087 -0
- package/dist/commerce.d.ts +34 -5
- package/dist/commerce.js +238 -10
- package/package.json +6 -2
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, CommerceError, commerceErrorInfoOf, createCommerceEventBuffer, createDerivedPurchaseIdempotencyKey, isTerminalCommercePurchaseState, } from './commerce.js';
|
|
2
|
+
import { createCreditCommerceProvider, createInMemoryCreditWallet, normalizeIsoTimestamp, } from './commerce-wallet.js';
|
|
3
|
+
// The wallet rules moved to `./commerce-wallet` with B7; they stay importable here.
|
|
4
|
+
export { addIsoDuration, compareCreditLotSpendOrder, deriveCreditLotSpendScope, normalizeIsoTimestamp, } from './commerce-wallet.js';
|
|
2
5
|
/**
|
|
3
6
|
* Commerce v2 conformance runner (RFC "Conformance vectors") with the in-memory
|
|
4
7
|
* reference pieces every vector needs: a fake clock, a fake store with its
|
|
@@ -32,31 +35,6 @@ export function createCommerceConformanceClock(initial = commerceConformanceDefa
|
|
|
32
35
|
},
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
|
-
/** Canonical `YYYY-MM-DDTHH:mm:ssZ`; milliseconds are kept only when non-zero. */
|
|
36
|
-
export function normalizeIsoTimestamp(input) {
|
|
37
|
-
const millis = Date.parse(input);
|
|
38
|
-
if (Number.isNaN(millis)) {
|
|
39
|
-
throw new TypeError(`Invalid ISO-8601 timestamp: ${input}`);
|
|
40
|
-
}
|
|
41
|
-
return formatIsoTimestamp(millis);
|
|
42
|
-
}
|
|
43
|
-
function formatIsoTimestamp(millis) {
|
|
44
|
-
const iso = new Date(millis).toISOString();
|
|
45
|
-
return iso.endsWith('.000Z') ? `${iso.slice(0, -5)}Z` : iso;
|
|
46
|
-
}
|
|
47
|
-
/** Adds an ISO-8601 duration (`P30D`, `P1M`, `PT12H`, ...) to a timestamp, in UTC calendar units. */
|
|
48
|
-
export function addIsoDuration(at, duration) {
|
|
49
|
-
const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/u
|
|
50
|
-
.exec(duration);
|
|
51
|
-
if (match === null || duration === 'P' || duration === 'PT') {
|
|
52
|
-
throw new TypeError(`Invalid ISO-8601 duration: ${duration}`);
|
|
53
|
-
}
|
|
54
|
-
const [, years, months, weeks, days, hours, minutes, seconds] = match;
|
|
55
|
-
const date = new Date(normalizeIsoTimestamp(at));
|
|
56
|
-
date.setUTCFullYear(date.getUTCFullYear() + Number(years ?? 0), date.getUTCMonth() + Number(months ?? 0), date.getUTCDate() + Number(weeks ?? 0) * 7 + Number(days ?? 0));
|
|
57
|
-
date.setUTCHours(date.getUTCHours() + Number(hours ?? 0), date.getUTCMinutes() + Number(minutes ?? 0), date.getUTCSeconds() + Number(seconds ?? 0));
|
|
58
|
-
return formatIsoTimestamp(date.getTime());
|
|
59
|
-
}
|
|
60
38
|
// Capabilities and fixture filling.
|
|
61
39
|
/** Capabilities of a vector profile: omitted blocks default to all supported and `available`. */
|
|
62
40
|
export function createCommerceConformanceCapabilities(profile) {
|
|
@@ -974,361 +952,76 @@ export function createCommerceConformanceRuntime(input) {
|
|
|
974
952
|
};
|
|
975
953
|
}
|
|
976
954
|
/**
|
|
977
|
-
*
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
* `INSUFFICIENT_BALANCE`; memberships debit `membership:<id>:<period>` per
|
|
982
|
-
* paid period and lapse at period end when the eligible balance runs out.
|
|
955
|
+
* The reference wallet (`createInMemoryCreditWallet`) on the vector clock,
|
|
956
|
+
* with the actor surface: fixture lots credit through `creditFromGrant`, the
|
|
957
|
+
* backend finalizer redeems through `finalize`, and owner-side mistakes in a
|
|
958
|
+
* vector surface as script errors.
|
|
983
959
|
*/
|
|
984
960
|
export function createCommerceConformanceWallet(input) {
|
|
985
|
-
const { clock
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
const subscriptions = new Map();
|
|
992
|
-
const subscriptionEvents = [];
|
|
993
|
-
const listeners = new Set();
|
|
994
|
-
const buffer = createCommerceEventBuffer();
|
|
995
|
-
const snapshotLot = (lot) => ({ ...lot });
|
|
996
|
-
const toEntitlement = (entitlement) => ({
|
|
997
|
-
entitlementId: entitlement.entitlementId,
|
|
998
|
-
titleId: entitlement.titleId,
|
|
999
|
-
logicalId: entitlement.logicalId,
|
|
1000
|
-
idempotencyKey: entitlement.idempotencyKey,
|
|
1001
|
-
createdAt: entitlement.createdAt,
|
|
1002
|
-
debits: [...entitlement.debits],
|
|
1003
|
-
});
|
|
1004
|
-
const toActiveSubscription = (subscription) => ({
|
|
1005
|
-
store,
|
|
1006
|
-
logicalId: subscription.logicalId,
|
|
1007
|
-
subscriptionId: subscription.subscriptionId,
|
|
1008
|
-
periodIndex: subscription.periodIndex,
|
|
1009
|
-
state: subscription.state,
|
|
1010
|
-
startedAt: subscription.startedAt,
|
|
1011
|
-
expiresAt: subscription.expiresAt,
|
|
1012
|
-
autoRenew: subscription.state === 'active',
|
|
961
|
+
const { clock } = input;
|
|
962
|
+
const wallet = createInMemoryCreditWallet({
|
|
963
|
+
store: input.store,
|
|
964
|
+
currencies: input.currencies,
|
|
965
|
+
now: () => clock.now(),
|
|
966
|
+
...(input.offers === undefined ? {} : { offers: input.offers }),
|
|
1013
967
|
});
|
|
1014
|
-
const emitSubscription = (subscription, type, periodIndex, reason) => {
|
|
1015
|
-
const eventId = `${subscription.subscriptionId}:${periodIndex}:${type}`;
|
|
1016
|
-
if (subscriptionEvents.some((event) => event.eventId === eventId)) {
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
subscriptionEvents.push({
|
|
1020
|
-
eventId,
|
|
1021
|
-
type,
|
|
1022
|
-
subscriptionId: subscription.subscriptionId,
|
|
1023
|
-
logicalId: subscription.logicalId,
|
|
1024
|
-
periodIndex,
|
|
1025
|
-
expiresAt: subscription.expiresAt,
|
|
1026
|
-
...(reason === undefined ? {} : { reason }),
|
|
1027
|
-
});
|
|
1028
|
-
const event = {
|
|
1029
|
-
type: 'subscriptionUpdated',
|
|
1030
|
-
subscription: toActiveSubscription(subscription),
|
|
1031
|
-
};
|
|
1032
|
-
if (listeners.size === 0) {
|
|
1033
|
-
buffer.push(event);
|
|
1034
|
-
return;
|
|
1035
|
-
}
|
|
1036
|
-
for (const listener of listeners) {
|
|
1037
|
-
listener(event);
|
|
1038
|
-
}
|
|
1039
|
-
};
|
|
1040
|
-
const eligibleLots = (titleId, currency, scope) => {
|
|
1041
|
-
const now = Date.parse(clock.now());
|
|
1042
|
-
return lots
|
|
1043
|
-
.filter((lot) => lot.currency === currency
|
|
1044
|
-
&& lot.remaining > 0
|
|
1045
|
-
&& (lot.expiresAt === undefined || Date.parse(lot.expiresAt) > now)
|
|
1046
|
-
&& (lot.spendScope.kind === 'cross-title'
|
|
1047
|
-
|| (scope === 'title' && lot.spendScope.titleId === titleId)))
|
|
1048
|
-
.sort(compareCreditLotSpendOrder);
|
|
1049
|
-
};
|
|
1050
|
-
const spendScoped = async (request, scope) => {
|
|
1051
|
-
const replay = spends.get(request.idempotencyKey);
|
|
1052
|
-
if (replay !== undefined) {
|
|
1053
|
-
return {
|
|
1054
|
-
status: 'debited',
|
|
1055
|
-
entitlementId: replay.entitlementId,
|
|
1056
|
-
debits: [...replay.debits],
|
|
1057
|
-
alreadyProcessed: true,
|
|
1058
|
-
};
|
|
1059
|
-
}
|
|
1060
|
-
if (!currencies.has(request.currency)) {
|
|
1061
|
-
return {
|
|
1062
|
-
status: 'rejected',
|
|
1063
|
-
error: {
|
|
1064
|
-
code: 'ITEM_UNAVAILABLE',
|
|
1065
|
-
retryable: false,
|
|
1066
|
-
message: `Currency ${request.currency} is not declared by the wallet.`,
|
|
1067
|
-
},
|
|
1068
|
-
};
|
|
1069
|
-
}
|
|
1070
|
-
if (!(request.amount > 0)) {
|
|
1071
|
-
return {
|
|
1072
|
-
status: 'rejected',
|
|
1073
|
-
error: {
|
|
1074
|
-
code: 'INVALID_RESPONSE',
|
|
1075
|
-
retryable: false,
|
|
1076
|
-
message: 'Spend amount must be positive.',
|
|
1077
|
-
},
|
|
1078
|
-
};
|
|
1079
|
-
}
|
|
1080
|
-
const candidates = eligibleLots(request.titleId, request.currency, scope);
|
|
1081
|
-
const available = candidates.reduce((sum, lot) => sum + lot.remaining, 0);
|
|
1082
|
-
if (available < request.amount) {
|
|
1083
|
-
return {
|
|
1084
|
-
status: 'rejected',
|
|
1085
|
-
error: {
|
|
1086
|
-
code: 'INSUFFICIENT_BALANCE',
|
|
1087
|
-
retryable: false,
|
|
1088
|
-
message: `Eligible ${request.currency} balance ${available} cannot cover ${request.amount}.`,
|
|
1089
|
-
},
|
|
1090
|
-
};
|
|
1091
|
-
}
|
|
1092
|
-
const spent = [];
|
|
1093
|
-
let remaining = request.amount;
|
|
1094
|
-
for (const lot of candidates) {
|
|
1095
|
-
if (remaining <= 0) {
|
|
1096
|
-
break;
|
|
1097
|
-
}
|
|
1098
|
-
const amount = Math.min(lot.remaining, remaining);
|
|
1099
|
-
lot.remaining -= amount;
|
|
1100
|
-
remaining -= amount;
|
|
1101
|
-
spent.push({ lotId: lot.lotId, amount });
|
|
1102
|
-
debits.push({ idempotencyKey: request.idempotencyKey, lotId: lot.lotId, amount });
|
|
1103
|
-
}
|
|
1104
|
-
const entitlementId = request.idempotencyKey;
|
|
1105
|
-
spends.set(request.idempotencyKey, { entitlementId, debits: spent });
|
|
1106
|
-
if (request.logicalId !== undefined) {
|
|
1107
|
-
entitlements.push({
|
|
1108
|
-
entitlementId,
|
|
1109
|
-
titleId: request.titleId,
|
|
1110
|
-
logicalId: request.logicalId,
|
|
1111
|
-
idempotencyKey: request.idempotencyKey,
|
|
1112
|
-
createdAt: clock.now(),
|
|
1113
|
-
debits: [...spent],
|
|
1114
|
-
redeemed: false,
|
|
1115
|
-
});
|
|
1116
|
-
}
|
|
1117
|
-
return { status: 'debited', entitlementId, debits: [...spent] };
|
|
1118
|
-
};
|
|
1119
|
-
const renew = async (subscription) => {
|
|
1120
|
-
while (subscription.state === 'active' && Date.parse(subscription.expiresAt) <= Date.parse(clock.now())) {
|
|
1121
|
-
const nextPeriod = subscription.periodIndex + 1;
|
|
1122
|
-
const result = await spendScoped({
|
|
1123
|
-
titleId: subscription.titleId,
|
|
1124
|
-
currency: subscription.terms.price.currency,
|
|
1125
|
-
amount: subscription.terms.price.amount,
|
|
1126
|
-
idempotencyKey: `membership:${subscription.subscriptionId}:${nextPeriod}`,
|
|
1127
|
-
}, subscription.terms.scope);
|
|
1128
|
-
if (result.status === 'rejected') {
|
|
1129
|
-
subscription.state = 'lapsed';
|
|
1130
|
-
emitSubscription(subscription, 'subscriptionLapsed', nextPeriod, result.error.code);
|
|
1131
|
-
return;
|
|
1132
|
-
}
|
|
1133
|
-
subscription.periodIndex = nextPeriod;
|
|
1134
|
-
subscription.expiresAt = addIsoDuration(subscription.expiresAt, subscription.terms.period);
|
|
1135
|
-
emitSubscription(subscription, 'subscriptionRenewed', nextPeriod);
|
|
1136
|
-
}
|
|
1137
|
-
};
|
|
1138
968
|
return {
|
|
1139
|
-
store,
|
|
969
|
+
store: wallet.store,
|
|
1140
970
|
get lots() {
|
|
1141
|
-
return lots
|
|
971
|
+
return wallet.lots;
|
|
1142
972
|
},
|
|
1143
973
|
get debits() {
|
|
1144
|
-
return
|
|
974
|
+
return wallet.debits;
|
|
1145
975
|
},
|
|
1146
976
|
get entitlements() {
|
|
1147
|
-
return entitlements
|
|
977
|
+
return wallet.entitlements;
|
|
1148
978
|
},
|
|
1149
979
|
get subscriptionEvents() {
|
|
1150
|
-
return
|
|
980
|
+
return wallet.subscriptionEvents;
|
|
1151
981
|
},
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
if (existing !== undefined) {
|
|
1155
|
-
return snapshotLot(existing);
|
|
1156
|
-
}
|
|
1157
|
-
if (!currencies.has(fixture.currency)) {
|
|
1158
|
-
throw new CommerceConformanceScriptError(`Lot ${fixture.ledgerEntryId} uses currency ${fixture.currency}, which the wallet does not declare.`);
|
|
1159
|
-
}
|
|
1160
|
-
const lot = {
|
|
1161
|
-
lotId: fixture.ledgerEntryId,
|
|
1162
|
-
currency: fixture.currency,
|
|
1163
|
-
amount: fixture.amount,
|
|
1164
|
-
remaining: fixture.amount,
|
|
1165
|
-
createdAt: normalizeIsoTimestamp(fixture.createdAt),
|
|
1166
|
-
...(fixture.expiresAt === undefined
|
|
1167
|
-
? {}
|
|
1168
|
-
: { expiresAt: normalizeIsoTimestamp(fixture.expiresAt) }),
|
|
1169
|
-
origin: { ...fixture.origin },
|
|
1170
|
-
spendScope: deriveCreditLotSpendScope(fixture.origin, options.spendScope),
|
|
1171
|
-
};
|
|
1172
|
-
lots.push(lot);
|
|
1173
|
-
return snapshotLot(lot);
|
|
982
|
+
get offers() {
|
|
983
|
+
return wallet.offers;
|
|
1174
984
|
},
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
const current = subscriptions.get(request.subscriptionId);
|
|
1178
|
-
if (current !== undefined && current.state === 'active') {
|
|
1179
|
-
return {
|
|
1180
|
-
status: 'started',
|
|
1181
|
-
subscription: toActiveSubscription(current),
|
|
1182
|
-
entitlementId: `membership:${request.subscriptionId}:0`,
|
|
1183
|
-
};
|
|
1184
|
-
}
|
|
1185
|
-
// The terms are validated before any credit moves, so malformed terms never charge.
|
|
1186
|
-
const startedAt = clock.now();
|
|
1187
|
-
let expiresAt;
|
|
985
|
+
addOffers: (offers) => wallet.addOffers(offers),
|
|
986
|
+
creditFromGrant(fixture, options = {}) {
|
|
1188
987
|
try {
|
|
1189
|
-
|
|
1190
|
-
}
|
|
1191
|
-
catch {
|
|
1192
|
-
return {
|
|
1193
|
-
status: 'rejected',
|
|
1194
|
-
error: {
|
|
1195
|
-
code: 'ITEM_UNAVAILABLE',
|
|
1196
|
-
retryable: false,
|
|
1197
|
-
message: `Membership period ${request.terms.period} is not an ISO-8601 duration.`,
|
|
1198
|
-
},
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
const result = await spendScoped({
|
|
1202
|
-
titleId: request.titleId,
|
|
1203
|
-
currency: request.terms.price.currency,
|
|
1204
|
-
amount: request.terms.price.amount,
|
|
1205
|
-
idempotencyKey: `membership:${request.subscriptionId}:0`,
|
|
1206
|
-
logicalId: request.logicalId,
|
|
1207
|
-
}, request.terms.scope);
|
|
1208
|
-
if (result.status === 'rejected') {
|
|
1209
|
-
return result;
|
|
1210
|
-
}
|
|
1211
|
-
const subscription = {
|
|
1212
|
-
subscriptionId: request.subscriptionId,
|
|
1213
|
-
titleId: request.titleId,
|
|
1214
|
-
logicalId: request.logicalId,
|
|
1215
|
-
terms: request.terms,
|
|
1216
|
-
periodIndex: 0,
|
|
1217
|
-
startedAt,
|
|
1218
|
-
expiresAt,
|
|
1219
|
-
state: 'active',
|
|
1220
|
-
};
|
|
1221
|
-
subscriptions.set(subscription.subscriptionId, subscription);
|
|
1222
|
-
emitSubscription(subscription, 'subscriptionStarted', 0);
|
|
1223
|
-
return {
|
|
1224
|
-
status: 'started',
|
|
1225
|
-
subscription: toActiveSubscription(subscription),
|
|
1226
|
-
entitlementId: result.entitlementId,
|
|
1227
|
-
};
|
|
1228
|
-
},
|
|
1229
|
-
async renewDue() {
|
|
1230
|
-
for (const subscription of subscriptions.values()) {
|
|
1231
|
-
await renew(subscription);
|
|
1232
|
-
}
|
|
1233
|
-
},
|
|
1234
|
-
async listOffers() {
|
|
1235
|
-
return [...(input.offers ?? [])];
|
|
1236
|
-
},
|
|
1237
|
-
async listUnredeemed(request) {
|
|
1238
|
-
return entitlements
|
|
1239
|
-
.filter((entitlement) => !entitlement.redeemed && entitlement.titleId === request.titleId)
|
|
1240
|
-
.map(toEntitlement);
|
|
1241
|
-
},
|
|
1242
|
-
async redeem(request) {
|
|
1243
|
-
const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.entitlementId);
|
|
1244
|
-
if (entitlement === undefined) {
|
|
1245
|
-
return { redeemed: false };
|
|
1246
|
-
}
|
|
1247
|
-
entitlement.redeemed = true;
|
|
1248
|
-
return { redeemed: true };
|
|
1249
|
-
},
|
|
1250
|
-
finalize(request) {
|
|
1251
|
-
const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.transactionId);
|
|
1252
|
-
if (entitlement !== undefined) {
|
|
1253
|
-
entitlement.redeemed = true;
|
|
988
|
+
return wallet.creditFromGrant(fixture, options.spendScope === undefined ? {} : { explicit: options.spendScope });
|
|
1254
989
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
const balances = {};
|
|
1258
|
-
for (const currency of currencies) {
|
|
1259
|
-
balances[currency] = eligibleLots(request.titleId, currency, 'title').reduce((sum, lot) => sum + lot.remaining, 0);
|
|
990
|
+
catch (error) {
|
|
991
|
+
throw new CommerceConformanceScriptError(describeError(error));
|
|
1260
992
|
}
|
|
1261
|
-
return { titleId: request.titleId, balances };
|
|
1262
993
|
},
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
}
|
|
1274
|
-
return () => {
|
|
1275
|
-
listeners.delete(listener);
|
|
1276
|
-
};
|
|
994
|
+
spend: (request) => wallet.spend(request),
|
|
995
|
+
startMembership: (request) => wallet.startMembership(request),
|
|
996
|
+
renewDue: () => wallet.renewDue(),
|
|
997
|
+
listOffers: (request) => wallet.listOffers(request),
|
|
998
|
+
listUnredeemed: (request) => wallet.listUnredeemed(request),
|
|
999
|
+
redeem: (request) => wallet.redeem(request),
|
|
1000
|
+
async finalize(request) {
|
|
1001
|
+
await wallet.redeem({
|
|
1002
|
+
entitlementId: request.transactionId,
|
|
1003
|
+
ledgerEntryId: request.ledgerEntryId,
|
|
1004
|
+
});
|
|
1277
1005
|
},
|
|
1006
|
+
getBalance: (request) => wallet.getBalance(request),
|
|
1007
|
+
getActiveSubscriptions: (request) => wallet.getActiveSubscriptions(request),
|
|
1008
|
+
subscribe: (listener, request) => wallet.subscribe(listener, request),
|
|
1278
1009
|
};
|
|
1279
1010
|
}
|
|
1280
|
-
/** Store lots are title-scoped, off-store lots cross-title, promotion lots as issued (title by default). */
|
|
1281
|
-
export function deriveCreditLotSpendScope(origin, explicit) {
|
|
1282
|
-
switch (origin.channel) {
|
|
1283
|
-
case 'store':
|
|
1284
|
-
return { kind: 'title', titleId: origin.titleId };
|
|
1285
|
-
case 'off-store':
|
|
1286
|
-
return { kind: 'cross-title' };
|
|
1287
|
-
case 'promotion':
|
|
1288
|
-
return explicit ?? { kind: 'title', titleId: origin.titleId };
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
/** RFC spend order: earliest `expiresAt` (none last), `title` before `cross-title`, oldest `createdAt`, then `lotId`. */
|
|
1292
|
-
export function compareCreditLotSpendOrder(left, right) {
|
|
1293
|
-
const leftExpiry = left.expiresAt === undefined
|
|
1294
|
-
? Number.POSITIVE_INFINITY
|
|
1295
|
-
: Date.parse(left.expiresAt);
|
|
1296
|
-
const rightExpiry = right.expiresAt === undefined
|
|
1297
|
-
? Number.POSITIVE_INFINITY
|
|
1298
|
-
: Date.parse(right.expiresAt);
|
|
1299
|
-
if (leftExpiry !== rightExpiry) {
|
|
1300
|
-
return leftExpiry < rightExpiry ? -1 : 1;
|
|
1301
|
-
}
|
|
1302
|
-
if (left.spendScope.kind !== right.spendScope.kind) {
|
|
1303
|
-
return left.spendScope.kind === 'title' ? -1 : 1;
|
|
1304
|
-
}
|
|
1305
|
-
const createdDelta = Date.parse(left.createdAt) - Date.parse(right.createdAt);
|
|
1306
|
-
if (createdDelta !== 0) {
|
|
1307
|
-
return createdDelta < 0 ? -1 : 1;
|
|
1308
|
-
}
|
|
1309
|
-
return left.lotId < right.lotId ? -1 : left.lotId > right.lotId ? 1 : 0;
|
|
1310
|
-
}
|
|
1311
1011
|
/**
|
|
1312
|
-
* `
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1315
|
-
*
|
|
1316
|
-
*
|
|
1317
|
-
*
|
|
1318
|
-
* `host`.
|
|
1012
|
+
* `createCreditCommerceProvider` over the reference wallet with the vector
|
|
1013
|
+
* profile as its capability block. A wallet that lists no offers has the
|
|
1014
|
+
* vector catalog registered as its offers first (a fixture price of one unit
|
|
1015
|
+
* of the first wallet currency for entries without membership terms), so the
|
|
1016
|
+
* wallet prices every spend and starts memberships itself. Memberships exist
|
|
1017
|
+
* only where the billing authority is `host`.
|
|
1319
1018
|
*/
|
|
1320
1019
|
export function createCommerceConformanceWalletProvider(input) {
|
|
1321
1020
|
const { wallet, catalog, titleId, clock } = input;
|
|
1322
|
-
const
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
const base = createCommerceConformanceCapabilities(input.profile);
|
|
1327
|
-
return {
|
|
1328
|
-
...base,
|
|
1329
|
-
purchase: { ...base.purchase, subscription: base.purchase.subscription && membershipsOffered },
|
|
1330
|
-
};
|
|
1331
|
-
};
|
|
1021
|
+
const defaultCurrency = input.currencies[0];
|
|
1022
|
+
if (defaultCurrency === undefined) {
|
|
1023
|
+
throw new CommerceConformanceScriptError('The wallet profile declares no currencies.');
|
|
1024
|
+
}
|
|
1332
1025
|
const toOffer = (entry) => ({
|
|
1333
1026
|
logicalId: entry.logicalId,
|
|
1334
1027
|
storeSku: entry.storeSku,
|
|
@@ -1339,112 +1032,23 @@ export function createCommerceConformanceWalletProvider(input) {
|
|
|
1339
1032
|
price: entry.membership?.price ?? { currency: defaultCurrency, amount: 1 },
|
|
1340
1033
|
...(entry.membership === undefined ? {} : { membership: entry.membership }),
|
|
1341
1034
|
});
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
purchasedAt: createdAt,
|
|
1357
|
-
token: entitlementId,
|
|
1358
|
-
evidence: { schema: `fixture.${store}.entitlement.v1`, payload: { entitlementId } },
|
|
1359
|
-
accountBinding: { kind: 'host-identity' },
|
|
1360
|
-
settlement: { ledgerEntryId: entitlementId },
|
|
1361
|
-
});
|
|
1362
|
-
const unavailable = (message) => ({
|
|
1363
|
-
outcome: 'failed',
|
|
1364
|
-
error: new CommerceError('ITEM_UNAVAILABLE', { message }).toCommerceErrorInfo(),
|
|
1365
|
-
});
|
|
1366
|
-
return {
|
|
1367
|
-
store,
|
|
1368
|
-
async getCapabilities() {
|
|
1369
|
-
return capabilities();
|
|
1370
|
-
},
|
|
1371
|
-
async fetchProducts(request) {
|
|
1372
|
-
return (await listOffers())
|
|
1373
|
-
.filter((offer) => matchesFetchProducts(offer, request))
|
|
1374
|
-
.map((offer) => ({
|
|
1375
|
-
logicalId: offer.logicalId,
|
|
1376
|
-
store,
|
|
1377
|
-
storeSku: offer.storeSku,
|
|
1378
|
-
kind: offer.kind,
|
|
1379
|
-
title: offer.title,
|
|
1380
|
-
description: offer.description,
|
|
1381
|
-
price: {
|
|
1382
|
-
formatted: `${offer.price.amount} ${offer.price.currency}`,
|
|
1383
|
-
currencyCode: offer.price.currency,
|
|
1384
|
-
},
|
|
1385
|
-
}));
|
|
1386
|
-
},
|
|
1387
|
-
async requestPurchase(request) {
|
|
1388
|
-
const offer = (await listOffers()).find((candidate) => candidate.logicalId === request.logicalId);
|
|
1389
|
-
if (offer === undefined) {
|
|
1390
|
-
return unavailable(`The wallet offers no product ${request.logicalId}.`);
|
|
1391
|
-
}
|
|
1392
|
-
const quantity = request.quantity ?? 1;
|
|
1393
|
-
if (quantity > 1 && !capabilities().purchase.quantity) {
|
|
1394
|
-
return unavailable('The wallet does not support purchase quantities above 1.');
|
|
1395
|
-
}
|
|
1396
|
-
if (offer.membership !== undefined) {
|
|
1397
|
-
if (quantity > 1) {
|
|
1398
|
-
return unavailable('A membership is purchased one period at a time.');
|
|
1399
|
-
}
|
|
1400
|
-
const started = await wallet.startMembership({
|
|
1401
|
-
subscriptionId: request.idempotencyKey,
|
|
1402
|
-
titleId,
|
|
1403
|
-
logicalId: offer.logicalId,
|
|
1404
|
-
terms: offer.membership,
|
|
1405
|
-
});
|
|
1406
|
-
if (started.status === 'rejected') {
|
|
1407
|
-
return { outcome: 'failed', error: started.error };
|
|
1408
|
-
}
|
|
1409
|
-
quantities.set(started.entitlementId, 1);
|
|
1410
|
-
return {
|
|
1411
|
-
outcome: 'purchased',
|
|
1412
|
-
purchase: toPurchase(offer, started.entitlementId, started.subscription.startedAt, 1),
|
|
1413
|
-
};
|
|
1414
|
-
}
|
|
1415
|
-
const spend = await wallet.spend({
|
|
1416
|
-
titleId,
|
|
1417
|
-
currency: offer.price.currency,
|
|
1418
|
-
amount: offer.price.amount * quantity,
|
|
1419
|
-
idempotencyKey: request.idempotencyKey,
|
|
1420
|
-
logicalId: offer.logicalId,
|
|
1421
|
-
});
|
|
1422
|
-
if (spend.status === 'rejected') {
|
|
1423
|
-
return { outcome: 'failed', error: spend.error };
|
|
1424
|
-
}
|
|
1425
|
-
quantities.set(spend.entitlementId, quantity);
|
|
1426
|
-
return {
|
|
1427
|
-
outcome: 'purchased',
|
|
1428
|
-
purchase: toPurchase(offer, spend.entitlementId, clock.now(), quantity),
|
|
1429
|
-
};
|
|
1430
|
-
},
|
|
1431
|
-
async getAvailablePurchases() {
|
|
1432
|
-
const offers = await listOffers();
|
|
1433
|
-
const unredeemed = await wallet.listUnredeemed({ titleId });
|
|
1434
|
-
return unredeemed.flatMap((entitlement) => {
|
|
1435
|
-
const offer = offers.find((candidate) => candidate.logicalId === entitlement.logicalId);
|
|
1436
|
-
return offer === undefined
|
|
1437
|
-
? []
|
|
1438
|
-
: [toPurchase(offer, entitlement.entitlementId, entitlement.createdAt, quantities.get(entitlement.entitlementId) ?? 1)];
|
|
1439
|
-
});
|
|
1440
|
-
},
|
|
1441
|
-
async getActiveSubscriptions() {
|
|
1442
|
-
return wallet.getActiveSubscriptions();
|
|
1443
|
-
},
|
|
1444
|
-
subscribe(listener) {
|
|
1445
|
-
return wallet.subscribe(listener);
|
|
1035
|
+
if (wallet.offers.length === 0) {
|
|
1036
|
+
wallet.addOffers(catalog.map(toOffer));
|
|
1037
|
+
}
|
|
1038
|
+
const capabilities = createCommerceConformanceCapabilities(input.profile);
|
|
1039
|
+
return createCreditCommerceProvider(wallet, {
|
|
1040
|
+
titleId,
|
|
1041
|
+
billingAuthority: input.billingAuthority,
|
|
1042
|
+
currencies: input.currencies,
|
|
1043
|
+
capabilities: {
|
|
1044
|
+
purchase: capabilities.purchase,
|
|
1045
|
+
catalog: capabilities.catalog,
|
|
1046
|
+
lifecycle: capabilities.lifecycle,
|
|
1047
|
+
authority: capabilities.authority,
|
|
1048
|
+
availability: capabilities.availability,
|
|
1446
1049
|
},
|
|
1447
|
-
|
|
1050
|
+
now: () => clock.now(),
|
|
1051
|
+
});
|
|
1448
1052
|
}
|
|
1449
1053
|
export function createCommerceConformanceHarness(context) {
|
|
1450
1054
|
if (context.vector.wallet === undefined) {
|
|
@@ -1503,7 +1107,7 @@ async function runVectorIn(vector, options, family) {
|
|
|
1503
1107
|
// A harness that cannot be built is a failed run, never a rejected promise.
|
|
1504
1108
|
let execution;
|
|
1505
1109
|
try {
|
|
1506
|
-
execution = await prepareExecution(vector, options.createProvider ?? createCommerceConformanceHarness, ids);
|
|
1110
|
+
execution = await prepareExecution(vector, options.createProvider ?? createCommerceConformanceHarness, ids, options.createRuntime);
|
|
1507
1111
|
}
|
|
1508
1112
|
catch (error) {
|
|
1509
1113
|
return failed([
|
|
@@ -1596,7 +1200,7 @@ async function runVectorIn(vector, options, family) {
|
|
|
1596
1200
|
...(adaptive === undefined ? {} : { adaptive }),
|
|
1597
1201
|
};
|
|
1598
1202
|
}
|
|
1599
|
-
async function prepareExecution(vector, createHarness, ids) {
|
|
1203
|
+
async function prepareExecution(vector, createHarness, ids, createRuntime) {
|
|
1600
1204
|
const clock = createCommerceConformanceClock();
|
|
1601
1205
|
const journal = createCommerceConformanceJournal();
|
|
1602
1206
|
const harnessRef = {};
|
|
@@ -1620,14 +1224,17 @@ async function prepareExecution(vector, createHarness, ids) {
|
|
|
1620
1224
|
};
|
|
1621
1225
|
const harness = await createHarness(context);
|
|
1622
1226
|
harnessRef.current = harness;
|
|
1623
|
-
const
|
|
1227
|
+
const runtimeInput = {
|
|
1624
1228
|
provider: harness.provider,
|
|
1625
1229
|
backend,
|
|
1626
1230
|
journal,
|
|
1627
1231
|
clock,
|
|
1628
1232
|
catalog: context.catalog,
|
|
1629
1233
|
playerId: ids.playerId,
|
|
1630
|
-
}
|
|
1234
|
+
};
|
|
1235
|
+
const runtime = createRuntime === undefined
|
|
1236
|
+
? createCommerceConformanceRuntime(runtimeInput)
|
|
1237
|
+
: await createRuntime(runtimeInput, context);
|
|
1631
1238
|
return { vector, harness, runtime, backend, journal, clock, adaptation };
|
|
1632
1239
|
}
|
|
1633
1240
|
/** Runs every step; a thrown step ends the run with a failure at that step. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CommerceCapabilities, LogicalProductId, PlatformEvidenceEnvelope, StarterLogicalProductId } from './index.js';
|
|
1
|
+
import type { CommerceCapabilities, CommerceFinishOwner, LogicalProductId, PlatformEvidenceEnvelope, StarterLogicalProductId } from './index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Commerce v2 client contract types (RFC "Client contract"). This module is
|
|
4
4
|
* typia-free on purpose: `./index` references `CommerceProvider` for
|
|
@@ -199,7 +199,13 @@ export interface CommerceProvider {
|
|
|
199
199
|
requestPurchase(input: RequestPurchaseInput): Promise<RequestPurchaseResult>;
|
|
200
200
|
/** Purchases the store still reports as unfinished. */
|
|
201
201
|
getAvailablePurchases(input?: GetAvailablePurchasesInput): Promise<readonly StorePurchase[]>;
|
|
202
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* Required when `lifecycle.finish` is 'client' or 'host'. Idempotent per
|
|
204
|
+
* transaction: the reconciler calls it again after a crash between a
|
|
205
|
+
* successful finish and its journal write, and on every pass while a
|
|
206
|
+
* finish keeps failing, so finishing an already finished transaction
|
|
207
|
+
* answers `{ finished: true }` rather than failing.
|
|
208
|
+
*/
|
|
203
209
|
finishTransaction?(input: FinishTransactionInput): Promise<FinishTransactionResult>;
|
|
204
210
|
/** Asks the store to resynchronize, for example `AppStore.sync`. */
|
|
205
211
|
restorePurchases?(): Promise<void>;
|
|
@@ -219,4 +225,27 @@ export interface ActiveSubscription {
|
|
|
219
225
|
readonly expiresAt: IsoTimestamp;
|
|
220
226
|
readonly autoRenew: boolean;
|
|
221
227
|
}
|
|
228
|
+
/** What the backend accepts for one store (RFC "Provider capabilities endpoint"). */
|
|
229
|
+
export interface CommerceServiceStoreCapabilities {
|
|
230
|
+
readonly store: StoreId;
|
|
231
|
+
/** Evidence schemas the backend routes for this store. */
|
|
232
|
+
readonly schemas: readonly NonEmptyString[];
|
|
233
|
+
/** Who finishes a granted purchase of this store, as the backend expects it. */
|
|
234
|
+
readonly finish: CommerceFinishOwner;
|
|
235
|
+
readonly purchase: {
|
|
236
|
+
readonly consumable: boolean;
|
|
237
|
+
readonly durable: boolean;
|
|
238
|
+
readonly subscription: boolean;
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The backend's published commerce capabilities (RFC "Provider capabilities
|
|
243
|
+
* endpoint"): the protocol version it speaks and one row per store its
|
|
244
|
+
* evidence registry accepts. The client runtime compares it with its
|
|
245
|
+
* provider's capability block at start.
|
|
246
|
+
*/
|
|
247
|
+
export interface CommerceServiceCapabilities {
|
|
248
|
+
readonly commerceProtocolVersion: NonEmptyString;
|
|
249
|
+
readonly stores: readonly CommerceServiceStoreCapabilities[];
|
|
250
|
+
}
|
|
222
251
|
export {};
|