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.
- package/dist/src/api/payment/dashboard-link.js +42 -68
- package/dist/src/api/payment/webhook.js +63 -389
- package/dist/src/api/personas/me.js +4 -7
- package/dist/src/cli/commands/sync.js +3 -2
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +86 -9
- package/dist/src/cli/setup/user-level-sync.js +51 -7
- package/dist/src/config/feature-flags.js +4 -0
- package/dist/src/core/handoff-contracts.js +8 -2
- package/dist/src/core/proposal-set-contract.js +105 -21
- package/dist/src/core/utils/server-startup.js +2 -2
- package/dist/src/fraim/current-license.js +51 -0
- package/dist/src/fraim/db-service.js +273 -36
- package/dist/src/fraim/license-presentation.js +41 -0
- package/dist/src/local-mcp-server/stdio-server.js +188 -73
- package/dist/src/local-mcp-server/usage-collector.js +20 -11
- package/dist/src/middleware/auth.js +44 -51
- package/dist/src/routes/analytics.js +3 -11
- package/dist/src/routes/auth-routes.js +4 -13
- package/dist/src/services/email-service.js +46 -32
- package/dist/src/services/mcp-service.js +19 -1
- package/dist/src/services/persona-entitlement-service.js +89 -31
- package/dist/src/services/subscription-payment.js +171 -0
- package/dist/src/services/subscription-provisioning.js +68 -0
- package/dist/src/services/subscription-reconciliation.js +147 -0
- package/dist/src/services/usage-analytics-service.js +8 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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.
|
|
540
|
-
update.
|
|
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
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|