fraim 2.0.352 → 2.0.353
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/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 +253 -30
- 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 +17 -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/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) {
|
|
@@ -327,9 +329,20 @@ class FraimDbService {
|
|
|
327
329
|
async verifyApiKey(key) {
|
|
328
330
|
if (!this.keysCollection)
|
|
329
331
|
throw new Error('DB not connected');
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
332
|
+
const credential = await this.keysCollection.findOne({ key });
|
|
333
|
+
if (!credential || credential.revokedAt)
|
|
334
|
+
return null;
|
|
335
|
+
if (credential.isActive === false) {
|
|
336
|
+
// Legacy expired/suspended records still report their lifecycle, but never inherit access.
|
|
337
|
+
return credential.status === 'expired' || credential.status === 'suspended' ? credential : null;
|
|
338
|
+
}
|
|
339
|
+
// A suspended trial is an explicit restriction, not an upgrade candidate.
|
|
340
|
+
if (credential.tier === 'trial' && credential.status === 'suspended')
|
|
341
|
+
return credential;
|
|
342
|
+
const records = await this.keysCollection.find({ userId: credential.userId }).toArray();
|
|
343
|
+
const paid = (0, current_license_1.selectCurrentLicense)(records.filter(record => record.tier === 'paid-subscription' && (0, current_license_1.sameLicenseScope)(credential, record)));
|
|
344
|
+
// Preserve the stored credential and its history; auth checks the current entitlement.
|
|
345
|
+
return paid || credential;
|
|
333
346
|
}
|
|
334
347
|
async getActiveSessionByApiKey(key) {
|
|
335
348
|
if (!this.keysCollection || !this.sessionsCollection)
|
|
@@ -349,13 +362,13 @@ class FraimDbService {
|
|
|
349
362
|
const apiKeyData = await this.verifyApiKey(key);
|
|
350
363
|
if (!apiKeyData)
|
|
351
364
|
return null;
|
|
365
|
+
return this.getSessionByUserIdAndSessionId(apiKeyData.userId, sessionId);
|
|
366
|
+
}
|
|
367
|
+
async getSessionByUserIdAndSessionId(userId, sessionId) {
|
|
368
|
+
if (!this.sessionsCollection)
|
|
369
|
+
throw new Error('DB not connected');
|
|
352
370
|
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
353
|
-
|
|
354
|
-
sessionId,
|
|
355
|
-
userId: apiKeyData.userId,
|
|
356
|
-
lastActive: { $gt: dayAgo }
|
|
357
|
-
});
|
|
358
|
-
return session;
|
|
371
|
+
return this.sessionsCollection.findOne({ sessionId, userId, lastActive: { $gt: dayAgo } });
|
|
359
372
|
}
|
|
360
373
|
async createAuthSession(input) {
|
|
361
374
|
if (!this.authSessionsCollection)
|
|
@@ -468,8 +481,9 @@ class FraimDbService {
|
|
|
468
481
|
key: newKey,
|
|
469
482
|
createdAt: new Date(),
|
|
470
483
|
isActive: true,
|
|
484
|
+
revokedAt: null,
|
|
471
485
|
});
|
|
472
|
-
await this.keysCollection.updateOne({ key: currentKey }, { $set: { isActive: false, status: 'expired' } });
|
|
486
|
+
await this.keysCollection.updateOne({ key: currentKey }, { $set: { isActive: false, status: 'expired', revokedAt: new Date() } });
|
|
473
487
|
return true;
|
|
474
488
|
}
|
|
475
489
|
async listApiKeys() {
|
|
@@ -489,7 +503,7 @@ class FraimDbService {
|
|
|
489
503
|
async revokeApiKey(key) {
|
|
490
504
|
if (!this.keysCollection)
|
|
491
505
|
throw new Error('DB not connected');
|
|
492
|
-
const result = await this.keysCollection.updateOne({ key }, { $set: { isActive: false } });
|
|
506
|
+
const result = await this.keysCollection.updateOne({ key }, { $set: { isActive: false, revokedAt: new Date() } });
|
|
493
507
|
return result.matchedCount > 0;
|
|
494
508
|
}
|
|
495
509
|
buildApiKeyTargetQuery(keys, userIds, labels = []) {
|
|
@@ -534,10 +548,11 @@ class FraimDbService {
|
|
|
534
548
|
const status = record.status === 'suspended' ? 'suspended' : nextStatus;
|
|
535
549
|
const update = {
|
|
536
550
|
expiresAt,
|
|
537
|
-
status
|
|
551
|
+
status,
|
|
552
|
+
manualAccessOverride: { until: expiresAt, grantedAt: new Date(), reason: 'admin' }
|
|
538
553
|
};
|
|
539
|
-
if (record.
|
|
540
|
-
update.
|
|
554
|
+
if (record.stripeSubscriptionId && record.billingReconciledAt) {
|
|
555
|
+
Object.assign(update, (0, current_license_1.subscriptionAccessPatch)({ ...record, ...update }));
|
|
541
556
|
}
|
|
542
557
|
await this.keysCollection.updateOne({ key: record.key }, { $set: update });
|
|
543
558
|
updatedKeys.push(record.key);
|
|
@@ -681,15 +696,16 @@ class FraimDbService {
|
|
|
681
696
|
async getApiKeyByUserId(userId, activeOnly = true) {
|
|
682
697
|
if (!this.db)
|
|
683
698
|
throw new Error('DB not connected');
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
699
|
+
const records = await this.db.collection('fraim_api_keys')
|
|
700
|
+
.find({ userId: userId.trim().toLowerCase() }).toArray();
|
|
701
|
+
// Sort in memory: Cosmos Mongo does not support arbitrary compound order-by queries.
|
|
702
|
+
const current = (0, current_license_1.selectCurrentLicense)(records, activeOnly);
|
|
703
|
+
if (current || activeOnly || records.length === 0)
|
|
704
|
+
return current;
|
|
705
|
+
// Retain account identity for sign-in/recovery and prevent signup issuing a fresh trial
|
|
706
|
+
// when every credential was revoked. Revocation is never projected as usable access.
|
|
707
|
+
const revoked = [...records].sort((a, b) => a.key.localeCompare(b.key))[0];
|
|
708
|
+
return { ...revoked, status: 'expired' };
|
|
693
709
|
}
|
|
694
710
|
async createAccessToken(data) {
|
|
695
711
|
if (!this.accessTokensCollection)
|
|
@@ -801,12 +817,145 @@ class FraimDbService {
|
|
|
801
817
|
async getApiKeyByStripeSubscriptionId(subscriptionId) {
|
|
802
818
|
if (!this.keysCollection)
|
|
803
819
|
throw new Error('DB not connected');
|
|
804
|
-
|
|
820
|
+
const rows = await this.keysCollection.find({ stripeSubscriptionId: subscriptionId }).toArray();
|
|
821
|
+
this.assertSubscriptionScope(rows);
|
|
822
|
+
// Billing must retain revoked subscription history; a checkout replay must not mint a replacement.
|
|
823
|
+
return (0, current_license_1.selectCurrentLicense)(rows) || rows.sort((a, b) => a.key.localeCompare(b.key))[0] || null;
|
|
805
824
|
}
|
|
806
825
|
async getApiKeyByStripeCustomerId(customerId) {
|
|
807
826
|
if (!this.keysCollection)
|
|
808
827
|
throw new Error('DB not connected');
|
|
809
|
-
return await this.keysCollection.
|
|
828
|
+
return (0, current_license_1.selectCurrentLicense)(await this.keysCollection.find({ stripeCustomerId: customerId }).toArray());
|
|
829
|
+
}
|
|
830
|
+
assertSubscriptionScope(rows) {
|
|
831
|
+
if (rows.some(row => !(0, current_license_1.sameLicenseScope)(rows[0], row))) {
|
|
832
|
+
throw new Error('Subscription is associated with conflicting account identities');
|
|
833
|
+
}
|
|
834
|
+
if (new Set(rows.map(row => row.stripeCustomerId).filter(Boolean)).size > 1) {
|
|
835
|
+
throw new Error('Subscription is associated with conflicting customer identities');
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/** Keep duplicate records of one subscription in agreement without deleting history or reviving revoked keys. */
|
|
839
|
+
async updateSubscriptionApiKeys(subscriptionId, update) {
|
|
840
|
+
if (!this.keysCollection)
|
|
841
|
+
throw new Error('DB not connected');
|
|
842
|
+
const rows = await this.keysCollection.find({ stripeSubscriptionId: subscriptionId }).toArray();
|
|
843
|
+
this.assertSubscriptionScope(rows);
|
|
844
|
+
if (update.stripeCustomerId && rows.some(row => row.stripeCustomerId && row.stripeCustomerId !== update.stripeCustomerId)) {
|
|
845
|
+
throw new Error('Subscription customer identity does not match');
|
|
846
|
+
}
|
|
847
|
+
for (const field of ['_id', 'key', 'userId', 'orgId', 'workspaceId', 'createdAt', 'isActive', 'revokedAt', 'stripeSubscriptionId']) {
|
|
848
|
+
if (field in update)
|
|
849
|
+
throw new Error('Subscription updates cannot change credential identity');
|
|
850
|
+
}
|
|
851
|
+
// Stripe terminal subscriptions cannot be resumed. Guard the write itself,
|
|
852
|
+
// since an active snapshot may have been fetched before cancellation won.
|
|
853
|
+
if (update.billingReconciledAt) {
|
|
854
|
+
let matched = false;
|
|
855
|
+
for (const original of rows) {
|
|
856
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
857
|
+
const row = await this.keysCollection.findOne({ key: original.key });
|
|
858
|
+
if (!row)
|
|
859
|
+
break;
|
|
860
|
+
if (row.stripeSubscriptionEndedAt && !update.stripeSubscriptionEndedAt)
|
|
861
|
+
break;
|
|
862
|
+
if (!update.stripeSubscriptionEndedAt && row.billingReconciledAt && row.billingReconciledAt > update.billingReconciledAt)
|
|
863
|
+
break;
|
|
864
|
+
const merged = this.mergeSubscriptionBilling(row, update);
|
|
865
|
+
const result = await this.keysCollection.updateOne({
|
|
866
|
+
key: row.key,
|
|
867
|
+
billingReconciledAt: row.billingReconciledAt || null,
|
|
868
|
+
stripeSubscriptionEndedAt: row.stripeSubscriptionEndedAt || null,
|
|
869
|
+
manualAccessOverride: row.manualAccessOverride || null
|
|
870
|
+
}, { $set: merged });
|
|
871
|
+
if (result.matchedCount) {
|
|
872
|
+
matched = true;
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
if (attempt === 3)
|
|
876
|
+
throw new Error('Subscription billing update did not converge');
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return matched;
|
|
880
|
+
}
|
|
881
|
+
const result = await this.keysCollection.updateMany({
|
|
882
|
+
stripeSubscriptionId: subscriptionId,
|
|
883
|
+
...(!update.stripeSubscriptionEndedAt ? { stripeSubscriptionEndedAt: null } : {})
|
|
884
|
+
}, { $set: update });
|
|
885
|
+
return result.matchedCount > 0;
|
|
886
|
+
}
|
|
887
|
+
mergeSubscriptionBilling(row, update) {
|
|
888
|
+
const merged = { ...update };
|
|
889
|
+
if (row.stripeSubscriptionEndedAt && merged.stripeSubscriptionEndedAt
|
|
890
|
+
&& row.stripeSubscriptionEndedAt < merged.stripeSubscriptionEndedAt) {
|
|
891
|
+
merged.stripeSubscriptionEndedAt = row.stripeSubscriptionEndedAt;
|
|
892
|
+
}
|
|
893
|
+
// Finite historical expiration edits may be support grants. Preserve their
|
|
894
|
+
// original deadline with explicit legacy provenance; never convert null
|
|
895
|
+
// subscription expirations into unlimited manual access.
|
|
896
|
+
if (!row.manualAccessOverride && !row.billingReconciledAt && row.expiresAt
|
|
897
|
+
&& (row.tier === 'paid-subscription' || (row.createdAt
|
|
898
|
+
&& row.expiresAt.getTime() > row.createdAt.getTime() + 14 * 86400000))) {
|
|
899
|
+
merged.manualAccessOverride = { until: row.expiresAt, grantedAt: new Date(), reason: 'legacy-expiration' };
|
|
900
|
+
}
|
|
901
|
+
if (row.paidThrough && (!merged.paidThrough || row.paidThrough > merged.paidThrough)) {
|
|
902
|
+
merged.paidThrough = row.paidThrough;
|
|
903
|
+
merged.paidPeriodStart = row.paidPeriodStart;
|
|
904
|
+
merged.paidInvoiceId = row.paidInvoiceId;
|
|
905
|
+
}
|
|
906
|
+
return { ...merged, ...(0, current_license_1.subscriptionAccessPatch)({ ...row, ...merged }) };
|
|
907
|
+
}
|
|
908
|
+
/** Reuse an expired trial in place. Subscription identity makes repeat/new-account provisioning idempotent. */
|
|
909
|
+
async provisionSubscriptionApiKey(userId, data) {
|
|
910
|
+
if (!this.keysCollection)
|
|
911
|
+
throw new Error('DB not connected');
|
|
912
|
+
const email = userId.trim().toLowerCase();
|
|
913
|
+
if (!email || !data.stripeSubscriptionId || !data.stripeCustomerId)
|
|
914
|
+
throw new Error('Subscription identity is required');
|
|
915
|
+
const patch = { ...data, tier: 'paid-subscription' };
|
|
916
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
917
|
+
const existing = await this.getApiKeyByStripeSubscriptionId(data.stripeSubscriptionId);
|
|
918
|
+
if (existing) {
|
|
919
|
+
if (existing.userId !== email || (existing.stripeCustomerId && existing.stripeCustomerId !== data.stripeCustomerId)) {
|
|
920
|
+
throw new Error('Subscription does not belong to this account');
|
|
921
|
+
}
|
|
922
|
+
const { stripeSubscriptionId, ...lifecycle } = patch;
|
|
923
|
+
await this.updateSubscriptionApiKeys(stripeSubscriptionId, lifecycle);
|
|
924
|
+
return (await this.getApiKeyByStripeSubscriptionId(stripeSubscriptionId));
|
|
925
|
+
}
|
|
926
|
+
const records = await this.keysCollection.find({ userId: email }).toArray();
|
|
927
|
+
const eligible = records.filter(record => record.isActive !== false && !record.revokedAt);
|
|
928
|
+
// Another checkout/webhook may have promoted the trial after the first lookup.
|
|
929
|
+
if (eligible.some(record => record.stripeSubscriptionId === data.stripeSubscriptionId))
|
|
930
|
+
continue;
|
|
931
|
+
if (eligible.some(record => !(0, current_license_1.sameLicenseScope)(eligible[0], record))) {
|
|
932
|
+
throw new Error('Checkout account scope is ambiguous');
|
|
933
|
+
}
|
|
934
|
+
const trial = (0, current_license_1.selectCurrentLicense)(eligible.filter(record => record.tier === 'trial'
|
|
935
|
+
&& record.status !== 'suspended' && !record.stripeSubscriptionId));
|
|
936
|
+
if (trial) {
|
|
937
|
+
const result = await this.keysCollection.updateOne({ key: trial.key, tier: 'trial', stripeSubscriptionId: null, isActive: { $ne: false }, status: { $ne: 'suspended' },
|
|
938
|
+
expiresAt: trial.expiresAt, manualAccessOverride: trial.manualAccessOverride || null }, { $set: data.billingReconciledAt ? this.mergeSubscriptionBilling(trial, patch) : patch });
|
|
939
|
+
if (result.matchedCount)
|
|
940
|
+
return (await this.getApiKeyByKey(trial.key));
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
const _id = new mongodb_1.ObjectId((0, node_crypto_1.createHash)('sha256').update(`fraim-subscription:${data.stripeSubscriptionId}`).digest('hex').slice(0, 24));
|
|
944
|
+
const prior = eligible[0];
|
|
945
|
+
try {
|
|
946
|
+
await this.keysCollection.updateOne({ _id }, { $setOnInsert: {
|
|
947
|
+
_id, key: this.generateApiKey(email, prior?.orgId || 'default'), userId: email,
|
|
948
|
+
orgId: prior?.orgId || 'default', workspaceId: prior?.workspaceId || null,
|
|
949
|
+
createdAt: new Date(), isActive: true, apiCallCount: 0, lastUsedAt: null, ...patch
|
|
950
|
+
} }, { upsert: true });
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
if (error.code !== 11000)
|
|
954
|
+
throw error;
|
|
955
|
+
}
|
|
956
|
+
// Re-read and validate the winner before applying lifecycle or returning a credential.
|
|
957
|
+
}
|
|
958
|
+
throw new Error('Subscription provisioning did not converge');
|
|
810
959
|
}
|
|
811
960
|
async getApiKeyByWorkspaceId(workspaceId, activeOnly = true) {
|
|
812
961
|
if (!this.keysCollection)
|
|
@@ -866,6 +1015,63 @@ class FraimDbService {
|
|
|
866
1015
|
async upsertPersonaEntitlement(entitlement) {
|
|
867
1016
|
if (!this.personaEntitlementsCollection)
|
|
868
1017
|
throw new Error('DB not connected');
|
|
1018
|
+
if (entitlement.hireMode === 'fulltime' && entitlement.stripeSubscriptionId && entitlement.purchaseSource !== 'manual') {
|
|
1019
|
+
const identity = { workspaceId: entitlement.workspaceId, personaKey: entitlement.personaKey, hireMode: entitlement.hireMode };
|
|
1020
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
1021
|
+
const existing = await this.getPersonaEntitlement(identity.workspaceId, identity.personaKey, identity.hireMode);
|
|
1022
|
+
if (existing && existing.stripeSubscriptionId !== entitlement.stripeSubscriptionId) {
|
|
1023
|
+
const incomingKey = await this.getApiKeyByStripeSubscriptionId(entitlement.stripeSubscriptionId);
|
|
1024
|
+
const existingKey = existing.stripeSubscriptionId
|
|
1025
|
+
? await this.getApiKeyByStripeSubscriptionId(existing.stripeSubscriptionId) : null;
|
|
1026
|
+
const incomingCreated = incomingKey?.stripeSubscriptionCreatedAt?.getTime();
|
|
1027
|
+
const existingCreated = existingKey?.stripeSubscriptionCreatedAt?.getTime();
|
|
1028
|
+
// Cancellation affects its own subscription only. Credential
|
|
1029
|
+
// creation dates cannot order purchases: trials are reused.
|
|
1030
|
+
if (incomingKey?.stripeSubscriptionEndedAt || entitlement.status !== 'active')
|
|
1031
|
+
return existing;
|
|
1032
|
+
if (Number.isFinite(incomingCreated) && Number.isFinite(existingCreated)) {
|
|
1033
|
+
if (incomingCreated <= existingCreated)
|
|
1034
|
+
return existing;
|
|
1035
|
+
}
|
|
1036
|
+
else if (existingKey ? (0, current_license_1.hasUsableLicense)(existingKey)
|
|
1037
|
+
: existing.status === 'active' && (!existing.expiresAt || existing.expiresAt.getTime() > Date.now())) {
|
|
1038
|
+
// Unknown purchase chronology must not displace a valid
|
|
1039
|
+
// existing hire or an explicitly authorized manual grant.
|
|
1040
|
+
return existing;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const now = new Date();
|
|
1044
|
+
if (!existing) {
|
|
1045
|
+
try {
|
|
1046
|
+
const inserted = await this.personaEntitlementsCollection.updateOne(identity, {
|
|
1047
|
+
$setOnInsert: { ...entitlement, createdAt: now, updatedAt: now }
|
|
1048
|
+
}, { upsert: true });
|
|
1049
|
+
if (!inserted.upsertedCount)
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
catch (error) {
|
|
1053
|
+
if (error.code === 11000)
|
|
1054
|
+
continue;
|
|
1055
|
+
throw error;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
const written = await this.personaEntitlementsCollection.updateOne({
|
|
1060
|
+
...identity, stripeSubscriptionId: existing.stripeSubscriptionId || null,
|
|
1061
|
+
updatedAt: existing.updatedAt, status: existing.status, expiresAt: existing.expiresAt
|
|
1062
|
+
}, { $set: { ...entitlement, updatedAt: now,
|
|
1063
|
+
purchasedCount: (existing.purchasedCount || 0)
|
|
1064
|
+
+ Number(existing.stripeSubscriptionId !== entitlement.stripeSubscriptionId)
|
|
1065
|
+
} });
|
|
1066
|
+
if (!written.matchedCount)
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
const stored = await this.getPersonaEntitlement(identity.workspaceId, identity.personaKey, identity.hireMode);
|
|
1070
|
+
if (stored)
|
|
1071
|
+
return stored;
|
|
1072
|
+
}
|
|
1073
|
+
throw new Error('Persona subscription ownership update did not converge');
|
|
1074
|
+
}
|
|
869
1075
|
const now = new Date();
|
|
870
1076
|
await this.personaEntitlementsCollection.updateOne({
|
|
871
1077
|
workspaceId: entitlement.workspaceId,
|
|
@@ -894,7 +1100,8 @@ class FraimDbService {
|
|
|
894
1100
|
personaKey,
|
|
895
1101
|
hireMode: 'job',
|
|
896
1102
|
status: 'active',
|
|
897
|
-
jobCreditsRemaining: { $gt: 0 }
|
|
1103
|
+
jobCreditsRemaining: { $gt: 0 },
|
|
1104
|
+
$or: [{ expiresAt: null }, { expiresAt: { $gt: new Date() } }]
|
|
898
1105
|
}, {
|
|
899
1106
|
$inc: { jobCreditsRemaining: -1 },
|
|
900
1107
|
$set: { updatedAt: new Date() }
|
|
@@ -905,12 +1112,25 @@ class FraimDbService {
|
|
|
905
1112
|
async recordWebhookEvent(event) {
|
|
906
1113
|
if (!this.webhookEventsCollection)
|
|
907
1114
|
throw new Error('DB not connected');
|
|
1115
|
+
const now = new Date();
|
|
908
1116
|
try {
|
|
909
|
-
await this.webhookEventsCollection.insertOne(event);
|
|
1117
|
+
await this.webhookEventsCollection.insertOne({ ...event, processingStartedAt: now });
|
|
910
1118
|
}
|
|
911
1119
|
catch (error) {
|
|
912
1120
|
if (error.code === 11000) {
|
|
913
|
-
|
|
1121
|
+
const reclaimed = await this.webhookEventsCollection.updateOne({
|
|
1122
|
+
eventId: event.eventId, processed: false,
|
|
1123
|
+
$or: [
|
|
1124
|
+
{ lastError: { $type: 'string' } },
|
|
1125
|
+
{ processingStartedAt: { $lt: new Date(now.getTime() - 5 * 60 * 1000) } },
|
|
1126
|
+
{ processingStartedAt: { $exists: false } }
|
|
1127
|
+
]
|
|
1128
|
+
}, { $set: { lastError: null, processingStartedAt: now } });
|
|
1129
|
+
if (reclaimed.matchedCount)
|
|
1130
|
+
return;
|
|
1131
|
+
if (!await this.isWebhookEventProcessed(event.eventId)) {
|
|
1132
|
+
throw new Error('Webhook event is processing');
|
|
1133
|
+
}
|
|
914
1134
|
throw new Error('Webhook event already processed');
|
|
915
1135
|
}
|
|
916
1136
|
throw error;
|
|
@@ -928,7 +1148,9 @@ class FraimDbService {
|
|
|
928
1148
|
const result = await this.webhookEventsCollection.updateOne({ eventId }, {
|
|
929
1149
|
$set: {
|
|
930
1150
|
processed: true,
|
|
931
|
-
processedAt: new Date()
|
|
1151
|
+
processedAt: new Date(),
|
|
1152
|
+
processingStartedAt: null,
|
|
1153
|
+
lastError: null
|
|
932
1154
|
}
|
|
933
1155
|
});
|
|
934
1156
|
return result.matchedCount > 0;
|
|
@@ -938,7 +1160,8 @@ class FraimDbService {
|
|
|
938
1160
|
throw new Error('DB not connected');
|
|
939
1161
|
const result = await this.webhookEventsCollection.updateOne({ eventId }, {
|
|
940
1162
|
$set: {
|
|
941
|
-
lastError: error
|
|
1163
|
+
lastError: error,
|
|
1164
|
+
processingStartedAt: null
|
|
942
1165
|
},
|
|
943
1166
|
$inc: {
|
|
944
1167
|
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
|
+
}
|