@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.
@@ -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
- * Reference in-memory wallet implementing the RFC "Credit lots" and "Billing
978
- * authority and memberships" rules: lot ids equal ledger entry ids, spend
979
- * scope derives from the lot origin, spends consume eligible lots in the
980
- * fixed order, are idempotent by key, and debit nothing on
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, store } = input;
986
- const currencies = new Set(input.currencies);
987
- const lots = [];
988
- const spends = new Map();
989
- const debits = [];
990
- const entitlements = [];
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.map(snapshotLot);
971
+ return wallet.lots;
1142
972
  },
1143
973
  get debits() {
1144
- return [...debits];
974
+ return wallet.debits;
1145
975
  },
1146
976
  get entitlements() {
1147
- return entitlements.map(toEntitlement);
977
+ return wallet.entitlements;
1148
978
  },
1149
979
  get subscriptionEvents() {
1150
- return [...subscriptionEvents];
980
+ return wallet.subscriptionEvents;
1151
981
  },
1152
- creditFromGrant(fixture, options = {}) {
1153
- const existing = lots.find((lot) => lot.lotId === fixture.ledgerEntryId);
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
- spend: (request) => spendScoped(request, 'title'),
1176
- async startMembership(request) {
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
- expiresAt = addIsoDuration(startedAt, request.terms.period);
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
- async getBalance(request) {
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
- async getActiveSubscriptions() {
1264
- return [...subscriptions.values()]
1265
- .filter((subscription) => subscription.state === 'active')
1266
- .map(toActiveSubscription);
1267
- },
1268
- subscribe(listener) {
1269
- const first = listeners.size === 0;
1270
- listeners.add(listener);
1271
- if (first) {
1272
- buffer.drain(listener);
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
- * `CommerceProvider` over the reference wallet: `fetchProducts` lists the
1313
- * wallet's offers (catalog-derived offers only when the wallet lists none),
1314
- * `requestPurchase` spends the offer price times the requested quantity
1315
- * (outcome `purchased`, `settlement` set, evidence adapter-settled),
1316
- * `getAvailablePurchases` lists unredeemed entitlements, and finishing is the
1317
- * backend's `redeem`. Memberships exist only where the billing authority is
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 store = input.profile.store;
1323
- const membershipsOffered = input.billingAuthority === 'host';
1324
- const defaultCurrency = input.currencies[0] ?? 'credits';
1325
- const capabilities = () => {
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
- /** The wallet's own offers decide availability and price; the catalog fills in only when it lists none. */
1343
- const listOffers = async () => {
1344
- const offers = await wallet.listOffers({ titleId });
1345
- return (offers.length === 0 ? catalog.map(toOffer) : offers).filter((offer) => offer.membership === undefined || membershipsOffered);
1346
- };
1347
- /** Quantities of purchases made through this provider, so re-listed entitlements keep them. */
1348
- const quantities = new Map();
1349
- const toPurchase = (offer, entitlementId, createdAt, quantity) => ({
1350
- store,
1351
- transactionId: entitlementId,
1352
- storeSku: offer.storeSku,
1353
- logicalId: offer.logicalId,
1354
- quantity,
1355
- state: 'purchased',
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 runtime = createCommerceConformanceRuntime({
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
- /** Required when `lifecycle.finish` is 'client' or 'host'. */
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 {};