fraim 2.0.352 → 2.0.354

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.
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.FraimDbService = void 0;
37
37
  const mongodb_1 = require("mongodb");
38
+ const node_crypto_1 = require("node:crypto");
39
+ const current_license_1 = require("./current-license");
38
40
  const git_utils_1 = require("../core/utils/git-utils");
39
41
  const quality_evidence_1 = require("../core/quality-evidence");
40
42
  function sanitizeSessionRepoContext(repo) {
@@ -186,6 +188,10 @@ class FraimDbService {
186
188
  await this.sessionsCollection.createIndex({ sessionId: 1 }, { unique: true });
187
189
  await this.sessionsCollection.createIndex({ userId: 1 });
188
190
  await this.sessionsCollection.createIndex({ lastActive: -1 });
191
+ // Issue #2093 — getActiveSessionByApiKey no longer bounds its match by recency, so a
192
+ // long-lived user's full session history is a candidate. This compound index keeps that
193
+ // lookup an indexed scan with an ordered result instead of a fetch-then-sort in memory.
194
+ await this.sessionsCollection.createIndex({ userId: 1, lastActive: -1 });
189
195
  await this.signupsCollection.createIndex({ email: 1 }, { unique: true });
190
196
  await this.signupsCollection.createIndex({ timestamp: -1 });
191
197
  await this.salesCollection.createIndex({ email: 1 });
@@ -327,21 +333,36 @@ class FraimDbService {
327
333
  async verifyApiKey(key) {
328
334
  if (!this.keysCollection)
329
335
  throw new Error('DB not connected');
330
- // Return the key regardless of isActive/status — the auth middleware
331
- // is responsible for checking expiry and returning the right HTTP status.
332
- return await this.keysCollection.findOne({ key });
336
+ const credential = await this.keysCollection.findOne({ key });
337
+ if (!credential || credential.revokedAt)
338
+ return null;
339
+ if (credential.isActive === false) {
340
+ // Legacy expired/suspended records still report their lifecycle, but never inherit access.
341
+ return credential.status === 'expired' || credential.status === 'suspended' ? credential : null;
342
+ }
343
+ // A suspended trial is an explicit restriction, not an upgrade candidate.
344
+ if (credential.tier === 'trial' && credential.status === 'suspended')
345
+ return credential;
346
+ const records = await this.keysCollection.find({ userId: credential.userId }).toArray();
347
+ const paid = (0, current_license_1.selectCurrentLicense)(records.filter(record => record.tier === 'paid-subscription' && (0, current_license_1.sameLicenseScope)(credential, record)));
348
+ // Preserve the stored credential and its history; auth checks the current entitlement.
349
+ return paid || credential;
333
350
  }
351
+ /**
352
+ * Most recently active session for the key's owner, at any age.
353
+ *
354
+ * Issue #2093 — a FRAIM work session has no lifetime. Elapsed time must never make an
355
+ * existing, owned session unresolvable, so this lookup filters on ownership only and
356
+ * leaves recency to the sort. Access is still bounded by `verifyApiKey`: an unknown,
357
+ * revoked or suspended credential resolves nothing.
358
+ */
334
359
  async getActiveSessionByApiKey(key) {
335
360
  if (!this.keysCollection || !this.sessionsCollection)
336
361
  throw new Error('DB not connected');
337
- // 1. Get user for this key
338
362
  const apiKeyData = await this.verifyApiKey(key);
339
363
  if (!apiKeyData)
340
364
  return null;
341
- // 2. Get latest session for this user (within last 24h)
342
- const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
343
- const session = await this.sessionsCollection.findOne({ userId: apiKeyData.userId, lastActive: { $gt: dayAgo } }, { sort: { lastActive: -1 } });
344
- return session;
365
+ return this.sessionsCollection.findOne({ userId: apiKeyData.userId }, { sort: { lastActive: -1 } });
345
366
  }
346
367
  async getSessionByApiKeyAndSessionId(key, sessionId) {
347
368
  if (!this.keysCollection || !this.sessionsCollection)
@@ -349,13 +370,19 @@ class FraimDbService {
349
370
  const apiKeyData = await this.verifyApiKey(key);
350
371
  if (!apiKeyData)
351
372
  return null;
352
- const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
353
- const session = await this.sessionsCollection.findOne({
354
- sessionId,
355
- userId: apiKeyData.userId,
356
- lastActive: { $gt: dayAgo }
357
- });
358
- return session;
373
+ return this.getSessionByUserIdAndSessionId(apiKeyData.userId, sessionId);
374
+ }
375
+ /**
376
+ * Exact session lookup scoped to its owner, at any age.
377
+ *
378
+ * Issue #2093 — resuming a session after days, months or years must return the original
379
+ * record so the run keeps its job, phase and review state. `userId` remains the only
380
+ * eligibility condition, so another account still cannot reach this session.
381
+ */
382
+ async getSessionByUserIdAndSessionId(userId, sessionId) {
383
+ if (!this.sessionsCollection)
384
+ throw new Error('DB not connected');
385
+ return this.sessionsCollection.findOne({ sessionId, userId });
359
386
  }
360
387
  async createAuthSession(input) {
361
388
  if (!this.authSessionsCollection)
@@ -468,8 +495,9 @@ class FraimDbService {
468
495
  key: newKey,
469
496
  createdAt: new Date(),
470
497
  isActive: true,
498
+ revokedAt: null,
471
499
  });
472
- await this.keysCollection.updateOne({ key: currentKey }, { $set: { isActive: false, status: 'expired' } });
500
+ await this.keysCollection.updateOne({ key: currentKey }, { $set: { isActive: false, status: 'expired', revokedAt: new Date() } });
473
501
  return true;
474
502
  }
475
503
  async listApiKeys() {
@@ -489,7 +517,7 @@ class FraimDbService {
489
517
  async revokeApiKey(key) {
490
518
  if (!this.keysCollection)
491
519
  throw new Error('DB not connected');
492
- const result = await this.keysCollection.updateOne({ key }, { $set: { isActive: false } });
520
+ const result = await this.keysCollection.updateOne({ key }, { $set: { isActive: false, revokedAt: new Date() } });
493
521
  return result.matchedCount > 0;
494
522
  }
495
523
  buildApiKeyTargetQuery(keys, userIds, labels = []) {
@@ -534,10 +562,11 @@ class FraimDbService {
534
562
  const status = record.status === 'suspended' ? 'suspended' : nextStatus;
535
563
  const update = {
536
564
  expiresAt,
537
- status
565
+ status,
566
+ manualAccessOverride: { until: expiresAt, grantedAt: new Date(), reason: 'admin' }
538
567
  };
539
- if (record.tier === 'paid-subscription') {
540
- update.currentPeriodEnd = expiresAt;
568
+ if (record.stripeSubscriptionId && record.billingReconciledAt) {
569
+ Object.assign(update, (0, current_license_1.subscriptionAccessPatch)({ ...record, ...update }));
541
570
  }
542
571
  await this.keysCollection.updateOne({ key: record.key }, { $set: update });
543
572
  updatedKeys.push(record.key);
@@ -681,15 +710,16 @@ class FraimDbService {
681
710
  async getApiKeyByUserId(userId, activeOnly = true) {
682
711
  if (!this.db)
683
712
  throw new Error('DB not connected');
684
- const query = { userId };
685
- if (activeOnly) {
686
- // Match active status OR legacy isActive:true with no status field.
687
- query.$or = [
688
- { status: 'active' },
689
- { status: { $exists: false }, isActive: true }
690
- ];
691
- }
692
- return await this.db.collection('fraim_api_keys').findOne(query);
713
+ const records = await this.db.collection('fraim_api_keys')
714
+ .find({ userId: userId.trim().toLowerCase() }).toArray();
715
+ // Sort in memory: Cosmos Mongo does not support arbitrary compound order-by queries.
716
+ const current = (0, current_license_1.selectCurrentLicense)(records, activeOnly);
717
+ if (current || activeOnly || records.length === 0)
718
+ return current;
719
+ // Retain account identity for sign-in/recovery and prevent signup issuing a fresh trial
720
+ // when every credential was revoked. Revocation is never projected as usable access.
721
+ const revoked = [...records].sort((a, b) => a.key.localeCompare(b.key))[0];
722
+ return { ...revoked, status: 'expired' };
693
723
  }
694
724
  async createAccessToken(data) {
695
725
  if (!this.accessTokensCollection)
@@ -801,12 +831,145 @@ class FraimDbService {
801
831
  async getApiKeyByStripeSubscriptionId(subscriptionId) {
802
832
  if (!this.keysCollection)
803
833
  throw new Error('DB not connected');
804
- return await this.keysCollection.findOne({ stripeSubscriptionId: subscriptionId });
834
+ const rows = await this.keysCollection.find({ stripeSubscriptionId: subscriptionId }).toArray();
835
+ this.assertSubscriptionScope(rows);
836
+ // Billing must retain revoked subscription history; a checkout replay must not mint a replacement.
837
+ return (0, current_license_1.selectCurrentLicense)(rows) || rows.sort((a, b) => a.key.localeCompare(b.key))[0] || null;
805
838
  }
806
839
  async getApiKeyByStripeCustomerId(customerId) {
807
840
  if (!this.keysCollection)
808
841
  throw new Error('DB not connected');
809
- return await this.keysCollection.findOne({ stripeCustomerId: customerId });
842
+ return (0, current_license_1.selectCurrentLicense)(await this.keysCollection.find({ stripeCustomerId: customerId }).toArray());
843
+ }
844
+ assertSubscriptionScope(rows) {
845
+ if (rows.some(row => !(0, current_license_1.sameLicenseScope)(rows[0], row))) {
846
+ throw new Error('Subscription is associated with conflicting account identities');
847
+ }
848
+ if (new Set(rows.map(row => row.stripeCustomerId).filter(Boolean)).size > 1) {
849
+ throw new Error('Subscription is associated with conflicting customer identities');
850
+ }
851
+ }
852
+ /** Keep duplicate records of one subscription in agreement without deleting history or reviving revoked keys. */
853
+ async updateSubscriptionApiKeys(subscriptionId, update) {
854
+ if (!this.keysCollection)
855
+ throw new Error('DB not connected');
856
+ const rows = await this.keysCollection.find({ stripeSubscriptionId: subscriptionId }).toArray();
857
+ this.assertSubscriptionScope(rows);
858
+ if (update.stripeCustomerId && rows.some(row => row.stripeCustomerId && row.stripeCustomerId !== update.stripeCustomerId)) {
859
+ throw new Error('Subscription customer identity does not match');
860
+ }
861
+ for (const field of ['_id', 'key', 'userId', 'orgId', 'workspaceId', 'createdAt', 'isActive', 'revokedAt', 'stripeSubscriptionId']) {
862
+ if (field in update)
863
+ throw new Error('Subscription updates cannot change credential identity');
864
+ }
865
+ // Stripe terminal subscriptions cannot be resumed. Guard the write itself,
866
+ // since an active snapshot may have been fetched before cancellation won.
867
+ if (update.billingReconciledAt) {
868
+ let matched = false;
869
+ for (const original of rows) {
870
+ for (let attempt = 0; attempt < 4; attempt++) {
871
+ const row = await this.keysCollection.findOne({ key: original.key });
872
+ if (!row)
873
+ break;
874
+ if (row.stripeSubscriptionEndedAt && !update.stripeSubscriptionEndedAt)
875
+ break;
876
+ if (!update.stripeSubscriptionEndedAt && row.billingReconciledAt && row.billingReconciledAt > update.billingReconciledAt)
877
+ break;
878
+ const merged = this.mergeSubscriptionBilling(row, update);
879
+ const result = await this.keysCollection.updateOne({
880
+ key: row.key,
881
+ billingReconciledAt: row.billingReconciledAt || null,
882
+ stripeSubscriptionEndedAt: row.stripeSubscriptionEndedAt || null,
883
+ manualAccessOverride: row.manualAccessOverride || null
884
+ }, { $set: merged });
885
+ if (result.matchedCount) {
886
+ matched = true;
887
+ break;
888
+ }
889
+ if (attempt === 3)
890
+ throw new Error('Subscription billing update did not converge');
891
+ }
892
+ }
893
+ return matched;
894
+ }
895
+ const result = await this.keysCollection.updateMany({
896
+ stripeSubscriptionId: subscriptionId,
897
+ ...(!update.stripeSubscriptionEndedAt ? { stripeSubscriptionEndedAt: null } : {})
898
+ }, { $set: update });
899
+ return result.matchedCount > 0;
900
+ }
901
+ mergeSubscriptionBilling(row, update) {
902
+ const merged = { ...update };
903
+ if (row.stripeSubscriptionEndedAt && merged.stripeSubscriptionEndedAt
904
+ && row.stripeSubscriptionEndedAt < merged.stripeSubscriptionEndedAt) {
905
+ merged.stripeSubscriptionEndedAt = row.stripeSubscriptionEndedAt;
906
+ }
907
+ // Finite historical expiration edits may be support grants. Preserve their
908
+ // original deadline with explicit legacy provenance; never convert null
909
+ // subscription expirations into unlimited manual access.
910
+ if (!row.manualAccessOverride && !row.billingReconciledAt && row.expiresAt
911
+ && (row.tier === 'paid-subscription' || (row.createdAt
912
+ && row.expiresAt.getTime() > row.createdAt.getTime() + 14 * 86400000))) {
913
+ merged.manualAccessOverride = { until: row.expiresAt, grantedAt: new Date(), reason: 'legacy-expiration' };
914
+ }
915
+ if (row.paidThrough && (!merged.paidThrough || row.paidThrough > merged.paidThrough)) {
916
+ merged.paidThrough = row.paidThrough;
917
+ merged.paidPeriodStart = row.paidPeriodStart;
918
+ merged.paidInvoiceId = row.paidInvoiceId;
919
+ }
920
+ return { ...merged, ...(0, current_license_1.subscriptionAccessPatch)({ ...row, ...merged }) };
921
+ }
922
+ /** Reuse an expired trial in place. Subscription identity makes repeat/new-account provisioning idempotent. */
923
+ async provisionSubscriptionApiKey(userId, data) {
924
+ if (!this.keysCollection)
925
+ throw new Error('DB not connected');
926
+ const email = userId.trim().toLowerCase();
927
+ if (!email || !data.stripeSubscriptionId || !data.stripeCustomerId)
928
+ throw new Error('Subscription identity is required');
929
+ const patch = { ...data, tier: 'paid-subscription' };
930
+ for (let attempt = 0; attempt < 4; attempt++) {
931
+ const existing = await this.getApiKeyByStripeSubscriptionId(data.stripeSubscriptionId);
932
+ if (existing) {
933
+ if (existing.userId !== email || (existing.stripeCustomerId && existing.stripeCustomerId !== data.stripeCustomerId)) {
934
+ throw new Error('Subscription does not belong to this account');
935
+ }
936
+ const { stripeSubscriptionId, ...lifecycle } = patch;
937
+ await this.updateSubscriptionApiKeys(stripeSubscriptionId, lifecycle);
938
+ return (await this.getApiKeyByStripeSubscriptionId(stripeSubscriptionId));
939
+ }
940
+ const records = await this.keysCollection.find({ userId: email }).toArray();
941
+ const eligible = records.filter(record => record.isActive !== false && !record.revokedAt);
942
+ // Another checkout/webhook may have promoted the trial after the first lookup.
943
+ if (eligible.some(record => record.stripeSubscriptionId === data.stripeSubscriptionId))
944
+ continue;
945
+ if (eligible.some(record => !(0, current_license_1.sameLicenseScope)(eligible[0], record))) {
946
+ throw new Error('Checkout account scope is ambiguous');
947
+ }
948
+ const trial = (0, current_license_1.selectCurrentLicense)(eligible.filter(record => record.tier === 'trial'
949
+ && record.status !== 'suspended' && !record.stripeSubscriptionId));
950
+ if (trial) {
951
+ const result = await this.keysCollection.updateOne({ key: trial.key, tier: 'trial', stripeSubscriptionId: null, isActive: { $ne: false }, status: { $ne: 'suspended' },
952
+ expiresAt: trial.expiresAt, manualAccessOverride: trial.manualAccessOverride || null }, { $set: data.billingReconciledAt ? this.mergeSubscriptionBilling(trial, patch) : patch });
953
+ if (result.matchedCount)
954
+ return (await this.getApiKeyByKey(trial.key));
955
+ continue;
956
+ }
957
+ const _id = new mongodb_1.ObjectId((0, node_crypto_1.createHash)('sha256').update(`fraim-subscription:${data.stripeSubscriptionId}`).digest('hex').slice(0, 24));
958
+ const prior = eligible[0];
959
+ try {
960
+ await this.keysCollection.updateOne({ _id }, { $setOnInsert: {
961
+ _id, key: this.generateApiKey(email, prior?.orgId || 'default'), userId: email,
962
+ orgId: prior?.orgId || 'default', workspaceId: prior?.workspaceId || null,
963
+ createdAt: new Date(), isActive: true, apiCallCount: 0, lastUsedAt: null, ...patch
964
+ } }, { upsert: true });
965
+ }
966
+ catch (error) {
967
+ if (error.code !== 11000)
968
+ throw error;
969
+ }
970
+ // Re-read and validate the winner before applying lifecycle or returning a credential.
971
+ }
972
+ throw new Error('Subscription provisioning did not converge');
810
973
  }
811
974
  async getApiKeyByWorkspaceId(workspaceId, activeOnly = true) {
812
975
  if (!this.keysCollection)
@@ -866,6 +1029,63 @@ class FraimDbService {
866
1029
  async upsertPersonaEntitlement(entitlement) {
867
1030
  if (!this.personaEntitlementsCollection)
868
1031
  throw new Error('DB not connected');
1032
+ if (entitlement.hireMode === 'fulltime' && entitlement.stripeSubscriptionId && entitlement.purchaseSource !== 'manual') {
1033
+ const identity = { workspaceId: entitlement.workspaceId, personaKey: entitlement.personaKey, hireMode: entitlement.hireMode };
1034
+ for (let attempt = 0; attempt < 8; attempt++) {
1035
+ const existing = await this.getPersonaEntitlement(identity.workspaceId, identity.personaKey, identity.hireMode);
1036
+ if (existing && existing.stripeSubscriptionId !== entitlement.stripeSubscriptionId) {
1037
+ const incomingKey = await this.getApiKeyByStripeSubscriptionId(entitlement.stripeSubscriptionId);
1038
+ const existingKey = existing.stripeSubscriptionId
1039
+ ? await this.getApiKeyByStripeSubscriptionId(existing.stripeSubscriptionId) : null;
1040
+ const incomingCreated = incomingKey?.stripeSubscriptionCreatedAt?.getTime();
1041
+ const existingCreated = existingKey?.stripeSubscriptionCreatedAt?.getTime();
1042
+ // Cancellation affects its own subscription only. Credential
1043
+ // creation dates cannot order purchases: trials are reused.
1044
+ if (incomingKey?.stripeSubscriptionEndedAt || entitlement.status !== 'active')
1045
+ return existing;
1046
+ if (Number.isFinite(incomingCreated) && Number.isFinite(existingCreated)) {
1047
+ if (incomingCreated <= existingCreated)
1048
+ return existing;
1049
+ }
1050
+ else if (existingKey ? (0, current_license_1.hasUsableLicense)(existingKey)
1051
+ : existing.status === 'active' && (!existing.expiresAt || existing.expiresAt.getTime() > Date.now())) {
1052
+ // Unknown purchase chronology must not displace a valid
1053
+ // existing hire or an explicitly authorized manual grant.
1054
+ return existing;
1055
+ }
1056
+ }
1057
+ const now = new Date();
1058
+ if (!existing) {
1059
+ try {
1060
+ const inserted = await this.personaEntitlementsCollection.updateOne(identity, {
1061
+ $setOnInsert: { ...entitlement, createdAt: now, updatedAt: now }
1062
+ }, { upsert: true });
1063
+ if (!inserted.upsertedCount)
1064
+ continue;
1065
+ }
1066
+ catch (error) {
1067
+ if (error.code === 11000)
1068
+ continue;
1069
+ throw error;
1070
+ }
1071
+ }
1072
+ else {
1073
+ const written = await this.personaEntitlementsCollection.updateOne({
1074
+ ...identity, stripeSubscriptionId: existing.stripeSubscriptionId || null,
1075
+ updatedAt: existing.updatedAt, status: existing.status, expiresAt: existing.expiresAt
1076
+ }, { $set: { ...entitlement, updatedAt: now,
1077
+ purchasedCount: (existing.purchasedCount || 0)
1078
+ + Number(existing.stripeSubscriptionId !== entitlement.stripeSubscriptionId)
1079
+ } });
1080
+ if (!written.matchedCount)
1081
+ continue;
1082
+ }
1083
+ const stored = await this.getPersonaEntitlement(identity.workspaceId, identity.personaKey, identity.hireMode);
1084
+ if (stored)
1085
+ return stored;
1086
+ }
1087
+ throw new Error('Persona subscription ownership update did not converge');
1088
+ }
869
1089
  const now = new Date();
870
1090
  await this.personaEntitlementsCollection.updateOne({
871
1091
  workspaceId: entitlement.workspaceId,
@@ -894,7 +1114,8 @@ class FraimDbService {
894
1114
  personaKey,
895
1115
  hireMode: 'job',
896
1116
  status: 'active',
897
- jobCreditsRemaining: { $gt: 0 }
1117
+ jobCreditsRemaining: { $gt: 0 },
1118
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }]
898
1119
  }, {
899
1120
  $inc: { jobCreditsRemaining: -1 },
900
1121
  $set: { updatedAt: new Date() }
@@ -905,12 +1126,25 @@ class FraimDbService {
905
1126
  async recordWebhookEvent(event) {
906
1127
  if (!this.webhookEventsCollection)
907
1128
  throw new Error('DB not connected');
1129
+ const now = new Date();
908
1130
  try {
909
- await this.webhookEventsCollection.insertOne(event);
1131
+ await this.webhookEventsCollection.insertOne({ ...event, processingStartedAt: now });
910
1132
  }
911
1133
  catch (error) {
912
1134
  if (error.code === 11000) {
913
- // Duplicate event ID - already processed
1135
+ const reclaimed = await this.webhookEventsCollection.updateOne({
1136
+ eventId: event.eventId, processed: false,
1137
+ $or: [
1138
+ { lastError: { $type: 'string' } },
1139
+ { processingStartedAt: { $lt: new Date(now.getTime() - 5 * 60 * 1000) } },
1140
+ { processingStartedAt: { $exists: false } }
1141
+ ]
1142
+ }, { $set: { lastError: null, processingStartedAt: now } });
1143
+ if (reclaimed.matchedCount)
1144
+ return;
1145
+ if (!await this.isWebhookEventProcessed(event.eventId)) {
1146
+ throw new Error('Webhook event is processing');
1147
+ }
914
1148
  throw new Error('Webhook event already processed');
915
1149
  }
916
1150
  throw error;
@@ -928,7 +1162,9 @@ class FraimDbService {
928
1162
  const result = await this.webhookEventsCollection.updateOne({ eventId }, {
929
1163
  $set: {
930
1164
  processed: true,
931
- processedAt: new Date()
1165
+ processedAt: new Date(),
1166
+ processingStartedAt: null,
1167
+ lastError: null
932
1168
  }
933
1169
  });
934
1170
  return result.matchedCount > 0;
@@ -938,7 +1174,8 @@ class FraimDbService {
938
1174
  throw new Error('DB not connected');
939
1175
  const result = await this.webhookEventsCollection.updateOne({ eventId }, {
940
1176
  $set: {
941
- lastError: error
1177
+ lastError: error,
1178
+ processingStartedAt: null
942
1179
  },
943
1180
  $inc: {
944
1181
  retryCount: 1
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.serializeLicenseLifecycle = serializeLicenseLifecycle;
4
+ const current_license_1 = require("./current-license");
5
+ function isoDate(value) {
6
+ if (!value)
7
+ return null;
8
+ const date = new Date(value);
9
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
10
+ }
11
+ /** Public billing metadata shared by account, session and manager responses. */
12
+ function serializeLicenseLifecycle(key) {
13
+ if (key?.stripeSubscriptionId && key.billingReconciledAt) {
14
+ key = { ...key, ...(0, current_license_1.subscriptionAccessPatch)(key) };
15
+ }
16
+ const recurring = Boolean(key?.stripeSubscriptionId);
17
+ const effectiveExpiry = key ? (0, current_license_1.effectiveLicenseExpiry)(key) : null;
18
+ const expiryUnknown = recurring && effectiveExpiry?.getTime() === 0 && !key?.paidThrough && !key?.manualAccessOverride;
19
+ return {
20
+ status: key ? key.status ?? 'active' : null,
21
+ expiresAt: expiryUnknown ? null : isoDate(effectiveExpiry),
22
+ tier: key?.tier ?? null,
23
+ billingSource: recurring ? 'stripe' : key?.tier === 'trial' ? 'trial'
24
+ : key?.manualAccessOverride ? 'manual' : 'unverified',
25
+ paidThrough: isoDate(key?.paidThrough),
26
+ paidPeriodStart: isoDate(key?.paidPeriodStart),
27
+ subscriptionStatus: key?.subscriptionStatus ?? null,
28
+ paymentStatus: key?.paymentStatus ?? (recurring ? 'unverified' : null),
29
+ billingInterval: key?.billingInterval ?? null,
30
+ billingIntervalCount: key?.billingIntervalCount ?? null,
31
+ currentPeriodEnd: isoDate(key?.currentPeriodEnd),
32
+ cancelAt: isoDate(key?.cancelAt),
33
+ billingReconciledAt: isoDate(key?.billingReconciledAt),
34
+ manualAccessOverride: key?.manualAccessOverride ? {
35
+ until: key.manualAccessOverride.until === null ? null
36
+ : isoDate(key.manualAccessOverride.until) ?? 'unverified',
37
+ grantedAt: isoDate(key.manualAccessOverride.grantedAt),
38
+ reason: key.manualAccessOverride.reason,
39
+ } : null,
40
+ };
41
+ }