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.
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readSubscriptionPayment = readSubscriptionPayment;
4
+ const stripe_1 = require("../config/stripe");
5
+ const idOf = (value) => typeof value === 'string' ? value : value?.id || null;
6
+ const MAX_PAGES = 10;
7
+ const PAGE_SIZE = 100;
8
+ function belongsTo(invoice, subscriptionId, customerId) {
9
+ const legacy = idOf(invoice.subscription);
10
+ const modern = idOf(invoice.parent?.subscription_details?.subscription);
11
+ return idOf(invoice.customer) === customerId && (legacy || modern) === subscriptionId
12
+ && (!legacy || legacy === subscriptionId) && (!modern || modern === subscriptionId)
13
+ && (!invoice.parent || invoice.parent.type === 'subscription_details');
14
+ }
15
+ async function allPages(first, next) {
16
+ const result = [];
17
+ const seen = new Set();
18
+ let page = first;
19
+ for (let index = 0; index < MAX_PAGES; index++) {
20
+ for (const entry of page.data) {
21
+ if (!entry.id || seen.has(entry.id))
22
+ throw new Error('Stripe payment proof pagination repeated an item');
23
+ seen.add(entry.id);
24
+ result.push(entry);
25
+ }
26
+ if (!page.has_more)
27
+ return result;
28
+ if (!page.data.length || index === MAX_PAGES - 1)
29
+ throw new Error('Stripe payment proof pagination is incomplete');
30
+ page = await next(page.data[page.data.length - 1].id);
31
+ }
32
+ throw new Error('Stripe payment proof pagination is incomplete');
33
+ }
34
+ /**
35
+ * Stripe owns billing amounts and discounts. A fully discounted or credit-funded
36
+ * invoice carries no balance, so Stripe finalizes it as paid with no cash to collect
37
+ * and no ledger entry at all. That is a settled service period, not an unpaid one.
38
+ */
39
+ function hasNoBalanceDue(invoice) {
40
+ // Only an absent balance field is treated as unreported; any present value must be
41
+ // an explicit zero, so a null or non-numeric balance denies rather than settles.
42
+ return invoice.amount_due === 0
43
+ && (invoice.amount_remaining === undefined || invoice.amount_remaining === 0);
44
+ }
45
+ async function isSettledInvoice(invoice, deps) {
46
+ if (invoice.status !== 'paid' || !Number.isSafeInteger(invoice.amount_paid)
47
+ || invoice.amount_paid < 0 || invoice.paid_out_of_band)
48
+ return false;
49
+ if (hasNoBalanceDue(invoice) && invoice.amount_paid === 0)
50
+ return true;
51
+ if (invoice.amount_paid <= 0)
52
+ return false;
53
+ // Older invoice versions explicitly distinguish collected from out-of-band
54
+ // payment. Clover instead represents externally recorded payments in its ledger.
55
+ if (!invoice.parent && invoice.subscription && invoice.paid_out_of_band === false)
56
+ return true;
57
+ const listPayments = deps.invoicePaymentLister || (params => stripe_1.stripe.invoicePayments.list(params));
58
+ const params = { invoice: invoice.id, status: 'paid', limit: PAGE_SIZE };
59
+ const payments = await allPages(invoice.payments || await listPayments(params), cursor => listPayments({ ...params, starting_after: cursor }));
60
+ const collected = payments.reduce((total, payment) => {
61
+ const source = payment.payment;
62
+ const collectedSource = source?.type === 'payment_intent' ? idOf(source.payment_intent)
63
+ : source?.type === 'charge' ? idOf(source.charge) : null;
64
+ return idOf(payment.invoice) === invoice.id && payment.status === 'paid' && collectedSource
65
+ && !!invoice.currency && payment.currency === invoice.currency
66
+ && Number.isSafeInteger(payment.amount_paid) && (payment.amount_paid || 0) > 0
67
+ ? total + payment.amount_paid : total;
68
+ }, 0);
69
+ return Number.isSafeInteger(collected) && collected >= invoice.amount_paid;
70
+ }
71
+ /**
72
+ * Payment proof comes only from settled invoices and their recurring service lines.
73
+ * Settlement is Stripe reporting the invoice paid with no balance outstanding, not a
74
+ * positive amount of cash: a 100% discount settles a period exactly as a charge does.
75
+ * An active subscription and its next billing date are deliberately insufficient.
76
+ * Each current recurring item must be covered; mixed-item coverage ends at the
77
+ * earliest item deadline. The returned invoice is the one constraining that date.
78
+ * Future intervals cannot bridge an unsettled gap. Prorations, credit lines, manually
79
+ * marked paid invoices, and incomplete/ambiguous item data grant nothing.
80
+ * Read failures throw so webhook/periodic callers can retry without replacing proof.
81
+ */
82
+ async function readSubscriptionPayment(subscription, deps = {}, now = new Date()) {
83
+ const items = subscription.items?.data || [];
84
+ const cadence = items.map(item => item.price?.recurring);
85
+ const firstCadence = cadence[0];
86
+ const sameCadence = !!firstCadence && Number.isSafeInteger(firstCadence.interval_count)
87
+ && firstCadence.interval_count > 0 && cadence.every(value => value?.interval === firstCadence.interval
88
+ && value?.interval_count === firstCadence.interval_count);
89
+ const result = {
90
+ paidThrough: null, paidPeriodStart: null, paidInvoiceId: null, paymentStatus: 'unverified',
91
+ billingInterval: sameCadence ? firstCadence.interval : null,
92
+ billingIntervalCount: sameCadence ? firstCadence.interval_count : null
93
+ };
94
+ const customerId = idOf(subscription.customer);
95
+ if (!customerId || !subscription.id || !Number.isFinite(now.getTime()) || !items.length
96
+ || subscription.items.has_more || items.some(item => !item.id || !item.price?.id || !item.price.recurring)
97
+ || new Set(items.map(item => item.id)).size !== items.length)
98
+ return result;
99
+ const listInvoices = deps.invoiceLister || (params => stripe_1.stripe.invoices.list(params));
100
+ const listLines = deps.invoiceLineLister || ((invoiceId, params) => stripe_1.stripe.invoices.listLineItems(invoiceId, params));
101
+ const retrieveInvoice = deps.invoiceRetriever || (invoiceId => stripe_1.stripe.invoices.retrieve(invoiceId));
102
+ const params = { customer: customerId, subscription: subscription.id, status: 'paid', limit: PAGE_SIZE };
103
+ const invoices = await allPages(await listInvoices(params), cursor => listInvoices({ ...params, starting_after: cursor }));
104
+ const latestId = idOf(subscription.latest_invoice);
105
+ const latest = latestId ? await retrieveInvoice(latestId) : null;
106
+ if (latest && (!belongsTo(latest, subscription.id, customerId) || latest.id !== latestId)) {
107
+ throw new Error('Latest Stripe invoice does not match subscription/customer');
108
+ }
109
+ // The freshly retrieved latest invoice supersedes its list snapshot. This also
110
+ // handles a just-paid invoice arriving before the paid-invoice list catches up.
111
+ if (latest) {
112
+ const index = invoices.findIndex(invoice => invoice.id === latest.id);
113
+ if (index >= 0)
114
+ invoices[index] = latest;
115
+ else
116
+ invoices.push(latest);
117
+ }
118
+ const latestUnpaid = latest && ['draft', 'open', 'uncollectible', 'void'].includes(latest.status || '');
119
+ if (latestUnpaid)
120
+ result.paymentStatus = 'unpaid';
121
+ const coverage = new Map();
122
+ const settledInvoices = new Set();
123
+ for (const invoice of invoices) {
124
+ if (!belongsTo(invoice, subscription.id, customerId) || !await isSettledInvoice(invoice, deps))
125
+ continue;
126
+ settledInvoices.add(invoice.id);
127
+ const lines = await allPages(invoice.lines, cursor => listLines(invoice.id, { limit: PAGE_SIZE, starting_after: cursor }));
128
+ for (const line of lines) {
129
+ const details = line.parent?.subscription_item_details;
130
+ const itemId = idOf(details?.subscription_item || line.subscription_item);
131
+ const item = items.find(candidate => candidate.id === itemId);
132
+ const priceId = idOf(line.pricing?.price_details?.price || line.price);
133
+ const lineSubscription = idOf(line.subscription);
134
+ const parentSubscription = idOf(details?.subscription);
135
+ // A settled invoice leaves nothing owed, so every recurring period it billed is
136
+ // covered whatever that line cost. Stripe folds an item discount into the line
137
+ // amount and an invoice discount into the total, and neither is unpaid service.
138
+ if (!item || priceId !== item.price.id || !(line.amount >= 0)
139
+ || details?.proration || line.proration
140
+ || (line.subscription_item && idOf(line.subscription_item) !== itemId)
141
+ || (line.price && idOf(line.price) !== priceId)
142
+ || (line.parent ? line.parent.type !== 'subscription_item_details' : line.type !== 'subscription')
143
+ || (!lineSubscription && !parentSubscription)
144
+ || (lineSubscription && lineSubscription !== subscription.id)
145
+ || (parentSubscription && parentSubscription !== subscription.id)
146
+ || (line.invoice && line.invoice !== invoice.id))
147
+ continue;
148
+ const { start, end } = line.period || {};
149
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end <= start
150
+ || start * 1000 > now.getTime() || !Number.isFinite(new Date(end * 1000).getTime()))
151
+ continue;
152
+ const previous = coverage.get(item.id);
153
+ if (!previous || end > previous.end || (end === previous.end && start < previous.start)) {
154
+ coverage.set(item.id, { start, end, invoiceId: invoice.id });
155
+ }
156
+ }
157
+ }
158
+ if (coverage.size !== items.length)
159
+ return result;
160
+ const periods = [...coverage.values()];
161
+ const limiting = periods.reduce((left, right) => left.end <= right.end ? left : right);
162
+ const start = Math.max(...periods.map(period => period.start));
163
+ if (start >= limiting.end)
164
+ return result;
165
+ result.paidPeriodStart = new Date(start * 1000);
166
+ result.paidThrough = new Date(limiting.end * 1000);
167
+ result.paidInvoiceId = limiting.invoiceId;
168
+ result.paymentStatus = latestUnpaid ? 'unpaid'
169
+ : latest && !settledInvoices.has(latest.id) ? 'unverified' : 'paid';
170
+ return result;
171
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.subscriptionCustomerId = subscriptionCustomerId;
4
+ exports.subscriptionPeriodEnd = subscriptionPeriodEnd;
5
+ exports.provisionSubscriptionApiKey = provisionSubscriptionApiKey;
6
+ const feature_flags_1 = require("../config/feature-flags");
7
+ const persona_hiring_1 = require("../config/persona-hiring");
8
+ const subscription_payment_1 = require("./subscription-payment");
9
+ const current_license_1 = require("../fraim/current-license");
10
+ const persona_entitlement_service_1 = require("./persona-entitlement-service");
11
+ function subscriptionCustomerId(subscription) {
12
+ return typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
13
+ }
14
+ /** Billing schedule only; never evidence that a service period was paid. */
15
+ function subscriptionPeriodEnd(subscription) {
16
+ const root = subscription.current_period_end;
17
+ const valid = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0;
18
+ const ends = valid(root) ? [root] : (subscription.items?.data || [])
19
+ .map(item => item.current_period_end).filter(valid);
20
+ return ends.length ? new Date(Math.min(...ends) * 1000) : null;
21
+ }
22
+ /** Reconcile verified payment, account transport and purchased scope together. */
23
+ async function provisionSubscriptionApiKey(dbService, email, subscription, currentPeriodEnd, paymentDeps = {}) {
24
+ const billingReconciledAt = new Date();
25
+ const persona = subscription.metadata?.plan === 'persona-hire';
26
+ if (persona && (!(0, persona_hiring_1.isPersonaHireKey)(subscription.metadata.personaKey) || subscription.metadata.hireMode !== 'fulltime')) {
27
+ throw new Error('Invalid recurring persona purchase scope');
28
+ }
29
+ const terminal = ['canceled', 'incomplete_expired'].includes(subscription.status);
30
+ const prior = await dbService.getApiKeyByStripeSubscriptionId(subscription.id);
31
+ const terminalSeconds = subscription.ended_at || subscription.canceled_at;
32
+ const endedAt = prior?.stripeSubscriptionEndedAt
33
+ || (terminalSeconds ? new Date(terminalSeconds * 1000) : billingReconciledAt);
34
+ // Cancellation must still terminate access if invoice retrieval is unavailable.
35
+ const payment = terminal ? {} : await (0, subscription_payment_1.readSubscriptionPayment)(subscription, paymentDeps, billingReconciledAt);
36
+ const billing = {
37
+ stripeCustomerId: subscriptionCustomerId(subscription),
38
+ stripeSubscriptionId: subscription.id,
39
+ ...(Number.isSafeInteger(subscription.created) && subscription.created > 0
40
+ ? { stripeSubscriptionCreatedAt: new Date(subscription.created * 1000) } : {}),
41
+ subscriptionStatus: subscription.status,
42
+ billingReconciledAt,
43
+ currentPeriodEnd,
44
+ cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000)
45
+ : subscription.cancel_at_period_end ? currentPeriodEnd : null,
46
+ accessScope: persona ? 'persona' : 'general',
47
+ ...(terminal ? { stripeSubscriptionEndedAt: endedAt } : {}),
48
+ ...payment,
49
+ ...(persona || (0, feature_flags_1.isPersonaEntitlementsEnabled)() ? { personaSystemActive: true } : {})
50
+ };
51
+ const key = await dbService.provisionSubscriptionApiKey(email.trim().toLowerCase(), {
52
+ ...billing, ...(0, current_license_1.subscriptionAccessPatch)({ ...prior, ...billing })
53
+ });
54
+ if (persona) {
55
+ // The entitlement store compares canonical subscription purchase dates and
56
+ // conditionally writes the observed owner, so concurrent deliveries converge.
57
+ if (!key.revokedAt && key.isActive !== false) {
58
+ await (0, persona_entitlement_service_1.syncPersonaEntitlementPurchase)(dbService, {
59
+ userId: key.userId, orgId: key.orgId,
60
+ stripeCustomerId: subscriptionCustomerId(subscription), stripeSubscriptionId: subscription.id,
61
+ stripeCheckoutSessionId: null, personaKey: subscription.metadata.personaKey,
62
+ hireMode: 'fulltime', purchaseSource: 'stripe-webhook',
63
+ status: key.status, expiresAt: key.expiresAt, metadata: subscription.metadata
64
+ });
65
+ }
66
+ }
67
+ return key;
68
+ }
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SubscriptionReconciliationService = void 0;
4
+ exports.subscriptionReconciliationEnabled = subscriptionReconciliationEnabled;
5
+ const stripe_1 = require("../config/stripe");
6
+ const subscription_provisioning_1 = require("./subscription-provisioning");
7
+ /** Development and test servers never contact Stripe unless explicitly enabled. */
8
+ function subscriptionReconciliationEnabled(env = process.env) {
9
+ return Boolean(env.STRIPE_SECRET_KEY) && env.FRAIM_SUBSCRIPTION_RECONCILIATION !== 'false'
10
+ && (env.NODE_ENV === 'production' || env.FRAIM_SUBSCRIPTION_RECONCILIATION === 'true');
11
+ }
12
+ /**
13
+ * Repair missed events using the same verified-invoice path as checkout/webhooks.
14
+ * Mongo streams subscription IDs in batches; duplicate and historical
15
+ * credentials are updated by their subscription without changing identity.
16
+ */
17
+ class SubscriptionReconciliationService {
18
+ constructor(dbService, dependencies = {}) {
19
+ this.dbService = dbService;
20
+ this.dependencies = dependencies;
21
+ this.stopped = false;
22
+ }
23
+ start(intervalMs = 60 * 60 * 1000) {
24
+ if (this.timer)
25
+ return;
26
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0)
27
+ throw new Error('Reconciliation interval must be positive');
28
+ this.stopped = false;
29
+ const sweep = () => {
30
+ // Sharing a pending promise prevents overlapping manual/scheduled sweeps.
31
+ if (this.pending)
32
+ return;
33
+ void this.runOnce().then(result => (this.dependencies.report || (summary => console.log('[subscription-reconciliation]', summary)))(result), error => (this.dependencies.reportError || (() => console.error('[subscription-reconciliation] Sweep failed; retrying next interval')))(error));
34
+ };
35
+ this.timer = setInterval(sweep, intervalMs);
36
+ this.timer.unref();
37
+ sweep();
38
+ }
39
+ async stop() {
40
+ this.stopped = true;
41
+ if (this.timer)
42
+ clearInterval(this.timer);
43
+ this.timer = undefined;
44
+ await this.pending;
45
+ }
46
+ runOnce() {
47
+ if (this.pending)
48
+ return this.pending;
49
+ if (this.stopped)
50
+ return Promise.resolve({ scanned: 0, reconciled: 0, failed: 0 });
51
+ this.pending = this.reconcile().finally(() => { this.pending = undefined; });
52
+ return this.pending;
53
+ }
54
+ async *subscriptionIds(db) {
55
+ const seen = new Set();
56
+ // Separate projected finds work with both MongoDB and Cosmos Mongo API.
57
+ // Keep only IDs in memory and close each cursor before opening the next.
58
+ for (const collection of ['fraim_api_keys', 'fraim_persona_entitlements']) {
59
+ if (this.stopped)
60
+ return;
61
+ const cursor = db.collection(collection).find({ stripeSubscriptionId: { $type: 'string', $ne: '' } }, { projection: { _id: 0, stripeSubscriptionId: 1 }, batchSize: 100 });
62
+ try {
63
+ for await (const row of cursor) {
64
+ if (this.stopped)
65
+ return;
66
+ if (seen.has(row.stripeSubscriptionId))
67
+ continue;
68
+ seen.add(row.stripeSubscriptionId);
69
+ yield row.stripeSubscriptionId;
70
+ }
71
+ }
72
+ finally {
73
+ await cursor.close();
74
+ }
75
+ }
76
+ }
77
+ async reconcile() {
78
+ const result = { scanned: 0, reconciled: 0, failed: 0 };
79
+ const db = await this.dbService.getDb();
80
+ const keys = db.collection('fraim_api_keys');
81
+ const entitlements = db.collection('fraim_persona_entitlements');
82
+ const subscriptions = this.subscriptionIds(db);
83
+ try {
84
+ for await (const subscriptionId of subscriptions) {
85
+ if (this.stopped)
86
+ break;
87
+ result.scanned++;
88
+ try {
89
+ // The database enforces account/workspace agreement across duplicates.
90
+ const existing = await this.dbService.getApiKeyByStripeSubscriptionId(subscriptionId);
91
+ const personaRecords = await entitlements.find({ stripeSubscriptionId: subscriptionId }).toArray();
92
+ const identity = existing || personaRecords[0];
93
+ if (!identity?.userId || !identity.stripeCustomerId)
94
+ throw new Error('Subscription has no stored customer identity');
95
+ if (personaRecords.some(record => record.userId !== identity.userId || record.orgId !== identity.orgId
96
+ || record.workspaceId !== identity.workspaceId || record.stripeCustomerId !== identity.stripeCustomerId)) {
97
+ throw new Error('Subscription persona scope mismatch');
98
+ }
99
+ if (!existing) {
100
+ // Older persona purchases recorded only an entitlement, leaving the
101
+ // account's trial credential expired. Repair within that saved scope.
102
+ const accountKey = await this.dbService.getApiKeyByUserId(identity.userId, false);
103
+ if (!accountKey || accountKey.revokedAt || accountKey.isActive === false
104
+ || accountKey.userId !== identity.userId || accountKey.orgId !== identity.orgId
105
+ || accountKey.workspaceId !== identity.workspaceId) {
106
+ throw new Error('Persona purchase has no unambiguous account scope');
107
+ }
108
+ }
109
+ const subscription = await (this.dependencies.subscriptionRetriever || (id => stripe_1.stripe.subscriptions.retrieve(id, {}, { timeout: 15000, maxNetworkRetries: 1 })))(subscriptionId);
110
+ if (subscription.id !== subscriptionId || (0, subscription_provisioning_1.subscriptionCustomerId)(subscription) !== identity.stripeCustomerId) {
111
+ throw new Error('Stripe subscription identity mismatch');
112
+ }
113
+ if (personaRecords.some(record => subscription.metadata.plan !== 'persona-hire'
114
+ || record.hireMode !== 'fulltime' || record.personaKey !== subscription.metadata.personaKey
115
+ || subscription.metadata.hireMode !== 'fulltime'))
116
+ throw new Error('Stripe persona purchase scope mismatch');
117
+ const customer = await (this.dependencies.customerRetriever || (id => stripe_1.stripe.customers.retrieve(id, {}, { timeout: 15000, maxNetworkRetries: 1 })))(identity.stripeCustomerId);
118
+ if (customer.deleted || customer.id !== identity.stripeCustomerId || !customer.email
119
+ || customer.email.trim().toLowerCase() !== identity.userId.trim().toLowerCase()) {
120
+ throw new Error('Stripe customer identity mismatch');
121
+ }
122
+ const conflictingKey = await keys.findOne({
123
+ stripeSubscriptionId: subscriptionId,
124
+ stripeCustomerId: { $ne: identity.stripeCustomerId }
125
+ }, { projection: { _id: 1 } });
126
+ if (conflictingKey)
127
+ throw new Error('Duplicate subscription customer identity mismatch');
128
+ if (this.stopped)
129
+ break;
130
+ await (this.dependencies.provision || subscription_provisioning_1.provisionSubscriptionApiKey)(this.dbService, customer.email, subscription, (0, subscription_provisioning_1.subscriptionPeriodEnd)(subscription));
131
+ result.reconciled++;
132
+ }
133
+ catch (error) {
134
+ // Preserve prior proof on retrieval failure. Other subscriptions still
135
+ // reconcile, and this exact subscription is retried on the next sweep.
136
+ result.failed++;
137
+ this.dependencies.reportError?.(error);
138
+ }
139
+ }
140
+ }
141
+ finally {
142
+ await subscriptions.return(undefined);
143
+ }
144
+ return result;
145
+ }
146
+ }
147
+ exports.SubscriptionReconciliationService = SubscriptionReconciliationService;
@@ -306,19 +306,21 @@ class UsageAnalyticsService {
306
306
  await this.logUsage(mentoringEvent);
307
307
  }
308
308
  /**
309
- * Validate that a jobId exists for the user (check recent usage events)
309
+ * Validate that this user started this jobId, regardless of how long ago.
310
310
  */
311
311
  async validateJobId(jobId, userId) {
312
312
  try {
313
- // Check if there's a recent job start event for this jobId and user
314
- const recentEvents = await this.dbService.getDb();
315
- const collection = recentEvents.collection('fraim_usage_events');
313
+ // Issue #2093 — a run resumed days or months later is still the same job, so
314
+ // this match is on job identity and ownership only. Adding an age condition
315
+ // here would silently drop analytics for exactly the long-gap resumes that
316
+ // the session fix is meant to support.
317
+ const events = await this.dbService.getDb();
318
+ const collection = events.collection('fraim_usage_events');
316
319
  const jobStartEvent = await collection.findOne({
317
320
  jobId,
318
321
  userId,
319
322
  type: 'job',
320
- 'args.action': 'start',
321
- createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } // Within last 24 hours
323
+ 'args.action': 'start'
322
324
  });
323
325
  return jobStartEvent !== null;
324
326
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.352",
3
+ "version": "2.0.354",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {