@taskforcehq/taskforce 0.3.301 → 0.3.303
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/Taskforce.module.css +37 -2
- package/dist/TaskforceCore.filter-persistence.test.js +1 -1
- package/dist/TaskforceCore.js +72 -33
- package/dist/TaskforceCore.test.js +848 -130
- package/dist/components/features/TaskSettings.js +14 -82
- package/dist/components/features/TaskSettings.test.js +33 -70
- package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
- package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
- package/dist/components/task/TaskActionHeader.js +6 -3
- package/dist/components/task/TaskCard.js +6 -3
- package/dist/components/task/TaskForm.js +2 -2
- package/dist/components/task/TaskForm.test.js +41 -2
- package/dist/components/task/TaskKanban.test.js +17 -0
- package/dist/components/ui/ReferenceBadgeButton.js +1 -1
- package/dist/components/views/PlansPage.d.ts +3 -1
- package/dist/components/views/PlansPage.js +109 -49
- package/dist/components/views/PlansPage.test.d.ts +1 -0
- package/dist/components/views/PlansPage.test.js +100 -0
- package/dist/components/views/StandaloneLayout.d.ts +1 -0
- package/dist/components/views/StandaloneLayout.js +473 -256
- package/dist/components/views/panels/PlanningDrawer.js +18 -1
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +48 -0
- package/dist/components/views/standalone/modals/AccountHubModal.js +36 -0
- package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
- package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
- package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
- package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
- package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
- package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
- package/dist/components/views/standalone/modals/HelpModal.js +8 -0
- package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
- package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
- package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
- package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
- package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
- package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
- package/dist/components/views/standalone/modals/types.d.ts +2 -0
- package/dist/components/views/standalone/modals/types.js +1 -0
- package/dist/components/views/teamManagementAccess.d.ts +1 -1
- package/dist/components/views/teamManagementAccess.js +2 -2
- package/dist/components/views/teamManagementAccess.test.js +3 -3
- package/dist/config/deployment.js +2 -2
- package/dist/config/deployment.test.js +5 -3
- package/dist/core/AiProfileService.js +7 -2
- package/dist/core/AiProfiles.test.js +19 -0
- package/dist/core/EntitlementsPolicy.test.js +2 -2
- package/dist/core/GlobalSettingsService.js +78 -18
- package/dist/core/PlanEntitlementService.d.ts +29 -5
- package/dist/core/PlanEntitlementService.js +297 -219
- package/dist/core/SignupMonetizationSettings.test.js +173 -49
- package/dist/core/SyncReconciliationService.d.ts +2 -0
- package/dist/core/SyncReconciliationService.js +3 -0
- package/dist/core/SystemAdmin.test.js +186 -96
- package/dist/core/TaskAuditTimeline.test.js +40 -0
- package/dist/core/Taskforce.d.ts +100 -10
- package/dist/core/Taskforce.ids.test.js +133 -0
- package/dist/core/Taskforce.js +459 -127
- package/dist/core/Taskforce.mcpAuth.test.js +0 -2
- package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
- package/dist/core/UserProfileAvatarDrafts.test.js +111 -0
- package/dist/core/taskReferencePolicy.d.ts +28 -0
- package/dist/core/taskReferencePolicy.js +189 -0
- package/dist/core/types.d.ts +13 -12
- package/dist/hooks/tasks/useTaskFormActions.js +3 -0
- package/dist/hooks/useSyncOrchestrator.js +17 -3
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
- package/dist/hooks/useTaskData.js +3 -2
- package/dist/hooks/useTaskData.test.js +66 -0
- package/dist/hooks/useTaskforce.d.ts +8 -0
- package/dist/hooks/useTaskforce.js +72 -19
- package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
- package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
- package/dist/mcp/aiProfileBootstrap.js +80 -0
- package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
- package/dist/mcp/aiProfileBootstrap.test.js +68 -0
- package/dist/mcp/clientIntegrations.d.ts +2 -8
- package/dist/mcp/clientIntegrations.js +21 -40
- package/dist/mcp/clientIntegrations.test.js +28 -2
- package/dist/mcp/clientProfileDefaults.d.ts +12 -0
- package/dist/mcp/clientProfileDefaults.js +37 -0
- package/dist/mcp/runtime.d.ts +10 -7
- package/dist/mcp/runtime.js +131 -48
- package/dist/mcp/runtime.test.js +321 -83
- package/dist/migrations/billingSchemaParity.test.js +3 -0
- package/dist/migrations/taskSchemaMigrations.js +68 -1
- package/dist/qaOpen.test.d.ts +1 -0
- package/dist/qaOpen.test.js +56 -0
- package/dist/server/index.cookieProxy.test.js +2 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +17 -1
- package/dist/server/index.test.js +15 -1
- package/dist/server/mockStripe.d.ts +1 -0
- package/dist/server/mockStripe.js +2 -0
- package/dist/server/routes/admin.js +105 -45
- package/dist/server/routes/auth.d.ts +6 -0
- package/dist/server/routes/auth.js +505 -30
- package/dist/server/routes/billing.js +517 -24
- package/dist/server/routes/billing.test.js +967 -45
- package/dist/server/routes/documents.js +6 -1
- package/dist/server/routes/shared.d.ts +1 -1
- package/dist/server/routes/shared.js +16 -6
- package/dist/server/routes/sync.integration.test.js +290 -0
- package/dist/server/routes/sync.js +234 -1922
- package/dist/server/routes/tasks.js +18 -4
- package/dist/server/routes.js +29 -22
- package/dist/server/routes.test.js +639 -82
- package/dist/shared/runtimeContract.d.ts +1 -1
- package/dist/sync/cloudSyncApi.d.ts +1 -0
- package/dist/sync/collaborationSyncPayload.d.ts +17 -0
- package/dist/sync/collaborationSyncPayload.js +131 -0
- package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
- package/dist/sync/collaborationSyncPayload.test.js +104 -0
- package/dist/sync/collaborationSyncState.d.ts +15 -0
- package/dist/sync/collaborationSyncState.js +60 -0
- package/dist/sync/contentSyncPayload.d.ts +45 -0
- package/dist/sync/contentSyncPayload.js +104 -0
- package/dist/sync/contentSyncPayload.test.d.ts +1 -0
- package/dist/sync/contentSyncPayload.test.js +104 -0
- package/dist/sync/contentSyncState.d.ts +98 -0
- package/dist/sync/contentSyncState.js +1004 -0
- package/dist/sync/planningSyncPayload.d.ts +9 -0
- package/dist/sync/planningSyncPayload.js +82 -0
- package/dist/sync/planningSyncPayload.test.d.ts +1 -0
- package/dist/sync/planningSyncPayload.test.js +68 -0
- package/dist/sync/syncApplyHandlers.d.ts +83 -0
- package/dist/sync/syncApplyHandlers.js +445 -0
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +21 -8
- package/dist/sync/taskSyncPayload.d.ts +33 -0
- package/dist/sync/taskSyncPayload.js +126 -0
- package/dist/sync/taskSyncPayload.test.d.ts +1 -0
- package/dist/sync/taskSyncPayload.test.js +54 -0
- package/dist/sync/workspacePullFeed.d.ts +38 -0
- package/dist/sync/workspacePullFeed.js +349 -0
- package/dist/sync/workspacePullFeed.test.d.ts +1 -0
- package/dist/sync/workspacePullFeed.test.js +278 -0
- package/dist/sync/workspaceSyncModel.d.ts +4 -1
- package/dist/sync/workspaceSyncModel.js +9 -2
- package/dist/sync/workspaceSyncModel.test.js +29 -0
- package/dist/test/setup.js +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/ui/assets/{AgentsModule-Bdq3T1Ts.js → AgentsModule-CpsTmrIW.js} +1 -1
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-jKUVo-PJ.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
- package/dist/ui/assets/{DocumentWorkspace-DuQa-uIC.js → DocumentWorkspace-C3GyzrWm.js} +2 -2
- package/dist/ui/assets/{InitiativesModule-C8Pi2VTk.js → InitiativesModule-4-Rh2K2n.js} +1 -1
- package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
- package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +8 -0
- package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
- package/dist/ui/assets/{WorkflowsModule-b6TLuHwJ.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
- package/dist/ui/assets/index-CLIMgR6g.js +6 -0
- package/dist/ui/assets/index-DneETxcu.css +1 -0
- package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-pWocEipT.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
- package/dist/utils/accountProfileSummaryCache.js +97 -0
- package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
- package/dist/utils/accountProfileSummaryCache.test.js +63 -0
- package/dist/utils/taskActivity.js +17 -4
- package/dist/utils/taskActivity.test.js +27 -0
- package/dist/utils/taskNormalization.js +10 -2
- package/dist/utils/taskReferences.d.ts +11 -1
- package/dist/utils/taskReferences.js +33 -1
- package/package.json +4 -3
- package/scripts/qa-open-lib.mjs +175 -0
- package/scripts/qa-open-lib.mjs.d.ts +49 -0
- package/scripts/qa-open.mjs +352 -0
- package/dist/ui/assets/PlansPage-B6BDECSi.js +0 -1
- package/dist/ui/assets/TaskSettings-DUJgRTPU.js +0 -8
- package/dist/ui/assets/index-BiiF1cLQ.css +0 -1
- package/dist/ui/assets/index-xBWLPOEb.js +0 -6
|
@@ -6,6 +6,8 @@ const BILLING_CONFIG_KEY_PLAN = 'billing.config.v1';
|
|
|
6
6
|
const PRICE_METADATA_PLAN_VERSION = 'taskforce_plan_version_id';
|
|
7
7
|
const BILLING_DIAGNOSTIC_RETENTION_DAYS = 30;
|
|
8
8
|
const BILLING_DIAGNOSTIC_MAX_PAYLOAD_BYTES = 8 * 1024;
|
|
9
|
+
const BILLING_CHECKOUT_ATTEMPT_RETENTION_DAYS = 14;
|
|
10
|
+
const BILLING_STATUS_RECONCILIATION_TTL_MS = 30_000;
|
|
9
11
|
const TASKFORCE_PROXY_ORIGIN_HOST_HEADER = 'x-taskforce-origin-host';
|
|
10
12
|
const TASKFORCE_PROXY_ORIGIN_PROTO_HEADER = 'x-taskforce-origin-proto';
|
|
11
13
|
const TASKFORCE_PROXY_ORIGIN_BASE_URL_HEADER = 'x-taskforce-origin-base-url';
|
|
@@ -25,6 +27,15 @@ function isoFromUnixSeconds(raw) {
|
|
|
25
27
|
return null;
|
|
26
28
|
return new Date(Math.floor(value) * 1000).toISOString();
|
|
27
29
|
}
|
|
30
|
+
function isIsoTimestampExpired(valueRaw) {
|
|
31
|
+
const value = String(valueRaw || '').trim();
|
|
32
|
+
if (!value)
|
|
33
|
+
return false;
|
|
34
|
+
const timestamp = Date.parse(value);
|
|
35
|
+
if (!Number.isFinite(timestamp))
|
|
36
|
+
return false;
|
|
37
|
+
return timestamp <= Date.now();
|
|
38
|
+
}
|
|
28
39
|
function parseInterval(raw) {
|
|
29
40
|
const value = String(raw || '').trim().toLowerCase();
|
|
30
41
|
if (value === 'month' || value === 'year')
|
|
@@ -229,6 +240,8 @@ function mapStripeStatusToCanonicalState(statusRaw) {
|
|
|
229
240
|
return 'active';
|
|
230
241
|
if (status === 'past_due')
|
|
231
242
|
return 'grace';
|
|
243
|
+
if (status === 'incomplete')
|
|
244
|
+
return 'checkout_pending';
|
|
232
245
|
return 'canceled';
|
|
233
246
|
}
|
|
234
247
|
function isMissingStripeCustomerError(error) {
|
|
@@ -473,14 +486,304 @@ function getEntitlementRow(core, userIdRaw) {
|
|
|
473
486
|
const db = core.getDatabaseAdapter();
|
|
474
487
|
const tenantId = core.getTenantId();
|
|
475
488
|
const row = db.prepare(`
|
|
476
|
-
SELECT account_id, plan_version_id, entitlement_state
|
|
489
|
+
SELECT account_id, plan_version_id, entitlement_state, effective_until, updated_by
|
|
477
490
|
FROM account_entitlements
|
|
478
491
|
WHERE tenant_id = ? AND account_id = ?
|
|
479
492
|
LIMIT 1
|
|
480
493
|
`).get(tenantId, userId);
|
|
481
494
|
return row || null;
|
|
482
495
|
}
|
|
496
|
+
function updateCheckoutAttemptStatus(core, sessionIdRaw, status) {
|
|
497
|
+
const sessionId = String(sessionIdRaw || '').trim();
|
|
498
|
+
if (!sessionId)
|
|
499
|
+
return;
|
|
500
|
+
const db = core.getDatabaseAdapter();
|
|
501
|
+
const tenantId = core.getTenantId();
|
|
502
|
+
db.prepare(`
|
|
503
|
+
UPDATE billing_checkout_attempts
|
|
504
|
+
SET status = ?, updated_at = ?
|
|
505
|
+
WHERE tenant_id = ? AND session_id = ?
|
|
506
|
+
`).run(status, new Date().toISOString(), tenantId, sessionId);
|
|
507
|
+
}
|
|
508
|
+
function expireOpenCheckoutAttempts(core) {
|
|
509
|
+
const db = core.getDatabaseAdapter();
|
|
510
|
+
const tenantId = core.getTenantId();
|
|
511
|
+
const now = new Date().toISOString();
|
|
512
|
+
const result = db.prepare(`
|
|
513
|
+
UPDATE billing_checkout_attempts
|
|
514
|
+
SET status = 'expired', updated_at = ?
|
|
515
|
+
WHERE tenant_id = ?
|
|
516
|
+
AND status = 'open'
|
|
517
|
+
AND expires_at IS NOT NULL
|
|
518
|
+
AND expires_at <> ''
|
|
519
|
+
AND expires_at <= ?
|
|
520
|
+
`).run(now, tenantId, now);
|
|
521
|
+
return Number(result?.changes || 0);
|
|
522
|
+
}
|
|
523
|
+
function purgeTerminalCheckoutAttempts(core) {
|
|
524
|
+
const db = core.getDatabaseAdapter();
|
|
525
|
+
const tenantId = core.getTenantId();
|
|
526
|
+
const cutoff = new Date(Date.now() - BILLING_CHECKOUT_ATTEMPT_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
|
|
527
|
+
const result = db.prepare(`
|
|
528
|
+
DELETE FROM billing_checkout_attempts
|
|
529
|
+
WHERE tenant_id = ?
|
|
530
|
+
AND status IN ('confirmed', 'superseded', 'expired')
|
|
531
|
+
AND updated_at <= ?
|
|
532
|
+
`).run(tenantId, cutoff);
|
|
533
|
+
return Number(result?.changes || 0);
|
|
534
|
+
}
|
|
535
|
+
function maintainCheckoutAttempts(core) {
|
|
536
|
+
const expiredRows = expireOpenCheckoutAttempts(core);
|
|
537
|
+
const purgedRows = purgeTerminalCheckoutAttempts(core);
|
|
538
|
+
return { expiredRows, purgedRows };
|
|
539
|
+
}
|
|
540
|
+
function normalizeCheckoutAttemptRow(core, row) {
|
|
541
|
+
if (!row)
|
|
542
|
+
return null;
|
|
543
|
+
const sessionId = String(row.session_id || '').trim();
|
|
544
|
+
const status = String(row.status || '').trim().toLowerCase();
|
|
545
|
+
if (!sessionId || status !== 'open' || !isIsoTimestampExpired(row.expires_at)) {
|
|
546
|
+
return row;
|
|
547
|
+
}
|
|
548
|
+
updateCheckoutAttemptStatus(core, sessionId, 'expired');
|
|
549
|
+
return {
|
|
550
|
+
...row,
|
|
551
|
+
status: 'expired',
|
|
552
|
+
updated_at: new Date().toISOString()
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function getCheckoutAttemptRow(core, sessionIdRaw) {
|
|
556
|
+
const sessionId = String(sessionIdRaw || '').trim();
|
|
557
|
+
if (!sessionId)
|
|
558
|
+
return null;
|
|
559
|
+
const db = core.getDatabaseAdapter();
|
|
560
|
+
const tenantId = core.getTenantId();
|
|
561
|
+
const row = db.prepare(`
|
|
562
|
+
SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
563
|
+
FROM billing_checkout_attempts
|
|
564
|
+
WHERE tenant_id = ? AND session_id = ?
|
|
565
|
+
LIMIT 1
|
|
566
|
+
`).get(tenantId, sessionId);
|
|
567
|
+
return normalizeCheckoutAttemptRow(core, row || null);
|
|
568
|
+
}
|
|
569
|
+
function getCurrentOpenCheckoutAttemptForUser(core, userIdRaw) {
|
|
570
|
+
const userId = String(userIdRaw || '').trim();
|
|
571
|
+
if (!userId)
|
|
572
|
+
return null;
|
|
573
|
+
const db = core.getDatabaseAdapter();
|
|
574
|
+
const tenantId = core.getTenantId();
|
|
575
|
+
const rows = db.prepare(`
|
|
576
|
+
SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
577
|
+
FROM billing_checkout_attempts
|
|
578
|
+
WHERE tenant_id = ? AND user_id = ? AND status = 'open'
|
|
579
|
+
ORDER BY created_at DESC, session_id DESC
|
|
580
|
+
`).all(tenantId, userId);
|
|
581
|
+
for (const row of rows) {
|
|
582
|
+
const normalized = normalizeCheckoutAttemptRow(core, row);
|
|
583
|
+
if (String(normalized?.status || '').trim().toLowerCase() === 'open') {
|
|
584
|
+
return normalized;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
function getLatestCheckoutAttemptForUser(core, userIdRaw) {
|
|
590
|
+
const userId = String(userIdRaw || '').trim();
|
|
591
|
+
if (!userId)
|
|
592
|
+
return null;
|
|
593
|
+
const db = core.getDatabaseAdapter();
|
|
594
|
+
const tenantId = core.getTenantId();
|
|
595
|
+
const row = db.prepare(`
|
|
596
|
+
SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
597
|
+
FROM billing_checkout_attempts
|
|
598
|
+
WHERE tenant_id = ? AND user_id = ?
|
|
599
|
+
ORDER BY created_at DESC, session_id DESC
|
|
600
|
+
LIMIT 1
|
|
601
|
+
`).get(tenantId, userId);
|
|
602
|
+
return normalizeCheckoutAttemptRow(core, row || null);
|
|
603
|
+
}
|
|
604
|
+
function resolveBillingConflictCode(core, input) {
|
|
605
|
+
const gate = String(input.gate || '').trim().toLowerCase();
|
|
606
|
+
if (!gate || gate === 'ok')
|
|
607
|
+
return null;
|
|
608
|
+
const latestAttempt = getLatestCheckoutAttemptForUser(core, input.userId);
|
|
609
|
+
const attemptStatus = String(latestAttempt?.status || '').trim().toLowerCase();
|
|
610
|
+
if (attemptStatus === 'expired')
|
|
611
|
+
return 'CHECKOUT_SESSION_EXPIRED';
|
|
612
|
+
if (attemptStatus === 'superseded')
|
|
613
|
+
return 'CHECKOUT_SESSION_SUPERSEDED';
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
function markLatestOpenCheckoutAttemptConfirmed(core, input) {
|
|
617
|
+
const planVersionId = String(input.planVersionId || '').trim();
|
|
618
|
+
if (!planVersionId)
|
|
619
|
+
return;
|
|
620
|
+
const currentAttempt = getCurrentOpenCheckoutAttemptForUser(core, input.userId);
|
|
621
|
+
if (!currentAttempt)
|
|
622
|
+
return;
|
|
623
|
+
const currentPlanVersionId = String(currentAttempt.plan_version_id || '').trim();
|
|
624
|
+
const sessionId = String(currentAttempt.session_id || '').trim();
|
|
625
|
+
if (!sessionId || currentPlanVersionId !== planVersionId)
|
|
626
|
+
return;
|
|
627
|
+
updateCheckoutAttemptStatus(core, sessionId, 'confirmed');
|
|
628
|
+
}
|
|
629
|
+
function recordCheckoutAttempt(core, input) {
|
|
630
|
+
const sessionId = String(input.sessionId || '').trim();
|
|
631
|
+
const userId = String(input.userId || '').trim();
|
|
632
|
+
const planVersionId = String(input.planVersionId || '').trim();
|
|
633
|
+
const billingInterval = parseInterval(input.billingInterval);
|
|
634
|
+
if (!sessionId || !userId || !planVersionId || !billingInterval)
|
|
635
|
+
return;
|
|
636
|
+
const db = core.getDatabaseAdapter();
|
|
637
|
+
const tenantId = core.getTenantId();
|
|
638
|
+
const now = new Date().toISOString();
|
|
639
|
+
const tx = db.transaction(() => {
|
|
640
|
+
db.prepare(`
|
|
641
|
+
UPDATE billing_checkout_attempts
|
|
642
|
+
SET status = 'superseded', updated_at = ?
|
|
643
|
+
WHERE tenant_id = ? AND user_id = ? AND status = 'open' AND session_id <> ?
|
|
644
|
+
`).run(now, tenantId, userId, sessionId);
|
|
645
|
+
db.prepare(`
|
|
646
|
+
INSERT INTO billing_checkout_attempts (
|
|
647
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
648
|
+
) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?)
|
|
649
|
+
ON CONFLICT (session_id) DO UPDATE SET
|
|
650
|
+
user_id = excluded.user_id,
|
|
651
|
+
plan_version_id = excluded.plan_version_id,
|
|
652
|
+
billing_interval = excluded.billing_interval,
|
|
653
|
+
status = 'open',
|
|
654
|
+
expires_at = excluded.expires_at,
|
|
655
|
+
updated_at = excluded.updated_at
|
|
656
|
+
`).run(sessionId, tenantId, userId, planVersionId, billingInterval, input.expiresAt || null, now, now);
|
|
657
|
+
});
|
|
658
|
+
tx();
|
|
659
|
+
}
|
|
660
|
+
function resolveCheckoutLifecycleGuard(core, input) {
|
|
661
|
+
const currentOpenAttempt = getCurrentOpenCheckoutAttemptForUser(core, input.userId);
|
|
662
|
+
const checkoutAttempt = input.sessionId
|
|
663
|
+
? getCheckoutAttemptRow(core, input.sessionId)
|
|
664
|
+
: currentOpenAttempt;
|
|
665
|
+
const attemptStatus = String(checkoutAttempt?.status || '').trim().toLowerCase();
|
|
666
|
+
const attemptPlanVersionId = String(checkoutAttempt?.plan_version_id || '').trim();
|
|
667
|
+
if (attemptStatus === 'expired') {
|
|
668
|
+
return {
|
|
669
|
+
blocked: true,
|
|
670
|
+
code: 'CHECKOUT_SESSION_EXPIRED',
|
|
671
|
+
message: 'This checkout session expired. Choose a plan and start checkout again.'
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
if (attemptStatus === 'superseded') {
|
|
675
|
+
return {
|
|
676
|
+
blocked: true,
|
|
677
|
+
code: 'CHECKOUT_SESSION_SUPERSEDED',
|
|
678
|
+
message: 'This checkout session is no longer current for this account.'
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (attemptStatus === 'open' && attemptPlanVersionId && input.planVersionId && attemptPlanVersionId !== input.planVersionId) {
|
|
682
|
+
return {
|
|
683
|
+
blocked: true,
|
|
684
|
+
code: 'CHECKOUT_SESSION_SUPERSEDED',
|
|
685
|
+
message: 'This checkout session is no longer current for this account.'
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
const currentBilling = getBillingMirrorRow(core, input.userId);
|
|
689
|
+
const currentSubscriptionId = String(currentBilling?.stripe_subscription_id || '').trim();
|
|
690
|
+
if (!currentOpenAttempt && input.subscriptionId && currentSubscriptionId && currentSubscriptionId !== input.subscriptionId) {
|
|
691
|
+
return {
|
|
692
|
+
blocked: true,
|
|
693
|
+
code: 'CHECKOUT_SESSION_SUPERSEDED',
|
|
694
|
+
message: 'This checkout session is no longer current for this account.'
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const entitlement = getEntitlementRow(core, input.userId);
|
|
698
|
+
if (!entitlement)
|
|
699
|
+
return { blocked: false };
|
|
700
|
+
const currentState = String(entitlement.entitlement_state || '').trim().toLowerCase();
|
|
701
|
+
const currentPlanVersionId = String(entitlement.plan_version_id || '').trim();
|
|
702
|
+
const updatedBy = String(entitlement.updated_by || '').trim().toLowerCase();
|
|
703
|
+
const expiredPendingCheckout = currentState === 'checkout_pending' && isIsoTimestampExpired(entitlement.effective_until);
|
|
704
|
+
if (expiredPendingCheckout || (currentState === 'pending_plan_selection' && updatedBy === 'checkout-expired')) {
|
|
705
|
+
return {
|
|
706
|
+
blocked: true,
|
|
707
|
+
code: 'CHECKOUT_SESSION_EXPIRED',
|
|
708
|
+
message: 'This checkout session expired. Choose a plan and start checkout again.'
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
if (currentState === 'checkout_pending'
|
|
712
|
+
&& currentPlanVersionId
|
|
713
|
+
&& input.planVersionId
|
|
714
|
+
&& currentPlanVersionId !== input.planVersionId) {
|
|
715
|
+
return {
|
|
716
|
+
blocked: true,
|
|
717
|
+
code: 'CHECKOUT_SESSION_SUPERSEDED',
|
|
718
|
+
message: 'This checkout session is no longer current for this account.'
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
return { blocked: false };
|
|
722
|
+
}
|
|
723
|
+
function isCommercialStateAppAccessible(stateRaw) {
|
|
724
|
+
const state = String(stateRaw || '').trim().toLowerCase();
|
|
725
|
+
return state === 'active' || state === 'trialing' || state === 'grace';
|
|
726
|
+
}
|
|
727
|
+
function shouldRecordCheckoutPendingState(entitlement) {
|
|
728
|
+
if (!entitlement)
|
|
729
|
+
return true;
|
|
730
|
+
const currentState = String(entitlement.entitlement_state || '').trim().toLowerCase();
|
|
731
|
+
return currentState === 'pending_plan_selection'
|
|
732
|
+
|| currentState === 'checkout_pending'
|
|
733
|
+
|| currentState === 'canceled';
|
|
734
|
+
}
|
|
735
|
+
function beginCheckoutPending(core, input) {
|
|
736
|
+
const planId = resolvePlanIdForVersion(core, input.planVersionId);
|
|
737
|
+
const updatedBy = input.updatedBy || 'checkout-start';
|
|
738
|
+
const source = input.source || 'checkout-start';
|
|
739
|
+
if (typeof core.transitionCanonicalCommercialState === 'function') {
|
|
740
|
+
core.transitionCanonicalCommercialState({
|
|
741
|
+
accountId: input.userId,
|
|
742
|
+
planVersionId: input.planVersionId,
|
|
743
|
+
state: 'checkout_pending',
|
|
744
|
+
effectiveUntil: input.effectiveUntil || null,
|
|
745
|
+
billingMirrorPlanId: planId,
|
|
746
|
+
source,
|
|
747
|
+
updatedBy
|
|
748
|
+
});
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const db = core.getDatabaseAdapter();
|
|
752
|
+
const tenantId = core.getTenantId();
|
|
753
|
+
const now = new Date().toISOString();
|
|
754
|
+
db.prepare(`
|
|
755
|
+
INSERT INTO account_entitlements (account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at)
|
|
756
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
757
|
+
ON CONFLICT (account_id) DO UPDATE SET
|
|
758
|
+
plan_version_id = excluded.plan_version_id,
|
|
759
|
+
entitlement_state = excluded.entitlement_state,
|
|
760
|
+
effective_at = excluded.effective_at,
|
|
761
|
+
effective_until = excluded.effective_until,
|
|
762
|
+
updated_by = excluded.updated_by,
|
|
763
|
+
updated_at = excluded.updated_at
|
|
764
|
+
`).run(input.userId, tenantId, input.planVersionId, 'checkout_pending', now, input.effectiveUntil || null, updatedBy, now, now);
|
|
765
|
+
upsertBillingMirror(core, {
|
|
766
|
+
userId: input.userId,
|
|
767
|
+
planId,
|
|
768
|
+
canonicalState: 'checkout_pending',
|
|
769
|
+
effectiveUntil: input.effectiveUntil || null
|
|
770
|
+
});
|
|
771
|
+
}
|
|
483
772
|
function upsertCanonicalEntitlement(core, input) {
|
|
773
|
+
const updatedBy = input.updatedBy || 'stripe-webhook';
|
|
774
|
+
if (typeof core.transitionCanonicalCommercialState === 'function') {
|
|
775
|
+
core.transitionCanonicalCommercialState({
|
|
776
|
+
accountId: input.userId,
|
|
777
|
+
planVersionId: input.planVersionId,
|
|
778
|
+
state: input.state,
|
|
779
|
+
effectiveAt: input.effectiveAt,
|
|
780
|
+
effectiveUntil: input.effectiveUntil,
|
|
781
|
+
billingMirrorPlanId: resolvePlanIdForVersion(core, input.planVersionId),
|
|
782
|
+
source: updatedBy === 'checkout-confirm' ? 'checkout-confirm' : 'stripe-webhook',
|
|
783
|
+
updatedBy
|
|
784
|
+
});
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
484
787
|
const db = core.getDatabaseAdapter();
|
|
485
788
|
const tenantId = core.getTenantId();
|
|
486
789
|
const now = new Date().toISOString();
|
|
@@ -494,7 +797,7 @@ function upsertCanonicalEntitlement(core, input) {
|
|
|
494
797
|
effective_until = excluded.effective_until,
|
|
495
798
|
updated_by = excluded.updated_by,
|
|
496
799
|
updated_at = excluded.updated_at
|
|
497
|
-
`).run(input.userId, tenantId, input.planVersionId, input.state, input.effectiveAt || now, input.effectiveUntil || null,
|
|
800
|
+
`).run(input.userId, tenantId, input.planVersionId, input.state, input.effectiveAt || now, input.effectiveUntil || null, updatedBy, now, now);
|
|
498
801
|
}
|
|
499
802
|
function upsertBillingMirror(core, input) {
|
|
500
803
|
const db = core.getDatabaseAdapter();
|
|
@@ -545,7 +848,12 @@ function applySubscriptionSnapshot(core, input) {
|
|
|
545
848
|
const canceledState = stripeStatus === 'canceled'
|
|
546
849
|
? (currentPeriodEnd && currentPeriodEnd > nowIso ? 'active' : 'canceled')
|
|
547
850
|
: null;
|
|
548
|
-
const
|
|
851
|
+
const mappedCanonicalState = canceledState || mapStripeStatusToCanonicalState(stripeStatus);
|
|
852
|
+
const preserveExistingAccess = mappedCanonicalState === 'checkout_pending'
|
|
853
|
+
&& isCommercialStateAppAccessible(existingEntitlement?.entitlement_state);
|
|
854
|
+
const canonicalState = preserveExistingAccess
|
|
855
|
+
? String(existingEntitlement?.entitlement_state || 'active').trim().toLowerCase()
|
|
856
|
+
: mappedCanonicalState;
|
|
549
857
|
const effectiveUntil = stripeStatus === 'canceled'
|
|
550
858
|
? currentPeriodEnd
|
|
551
859
|
: (currentPeriodEnd || null);
|
|
@@ -562,7 +870,17 @@ function applySubscriptionSnapshot(core, input) {
|
|
|
562
870
|
currentPeriodEnd,
|
|
563
871
|
effectiveUntil
|
|
564
872
|
});
|
|
565
|
-
if (planVersionId) {
|
|
873
|
+
if (planVersionId && canonicalState === 'checkout_pending') {
|
|
874
|
+
if (shouldRecordCheckoutPendingState(existingEntitlement)) {
|
|
875
|
+
beginCheckoutPending(core, {
|
|
876
|
+
userId,
|
|
877
|
+
planVersionId,
|
|
878
|
+
source,
|
|
879
|
+
updatedBy: source
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
else if (planVersionId) {
|
|
566
884
|
upsertCanonicalEntitlement(core, {
|
|
567
885
|
userId,
|
|
568
886
|
planVersionId: planVersionId || existingEntitlement?.plan_version_id || core.getDefaultPlanVersionIdByMode('personal', { allowFallback: true }),
|
|
@@ -623,12 +941,18 @@ function getBillingMirrorRow(core, userIdRaw) {
|
|
|
623
941
|
const db = core.getDatabaseAdapter();
|
|
624
942
|
const tenantId = core.getTenantId();
|
|
625
943
|
return db.prepare(`
|
|
626
|
-
SELECT stripe_status, stripe_customer_id, stripe_subscription_id, stripe_price_id, billing_interval, trial_end, current_period_end, effective_until, entitlement_state, tier
|
|
944
|
+
SELECT stripe_status, stripe_customer_id, stripe_subscription_id, stripe_price_id, billing_interval, trial_end, current_period_end, effective_until, entitlement_state, tier, updated_at
|
|
627
945
|
FROM user_billing
|
|
628
946
|
WHERE tenant_id = ? AND user_id = ?
|
|
629
947
|
LIMIT 1
|
|
630
948
|
`).get(tenantId, userId);
|
|
631
949
|
}
|
|
950
|
+
function isBillingMirrorFresh(row) {
|
|
951
|
+
const updatedAt = Date.parse(String(row?.updated_at || ''));
|
|
952
|
+
if (!Number.isFinite(updatedAt))
|
|
953
|
+
return false;
|
|
954
|
+
return (Date.now() - updatedAt) < BILLING_STATUS_RECONCILIATION_TTL_MS;
|
|
955
|
+
}
|
|
632
956
|
function isStripeManagedSubscriptionStatus(statusRaw) {
|
|
633
957
|
const status = String(statusRaw || '').trim().toLowerCase();
|
|
634
958
|
return status === 'trialing'
|
|
@@ -708,6 +1032,9 @@ async function reconcileBillingMirrorIfNeeded(core, userIdRaw) {
|
|
|
708
1032
|
if (!userId)
|
|
709
1033
|
return { row: null, conflictCode: null };
|
|
710
1034
|
let row = getBillingMirrorRow(core, userId);
|
|
1035
|
+
if (isBillingMirrorFresh(row)) {
|
|
1036
|
+
return { row, conflictCode: null };
|
|
1037
|
+
}
|
|
711
1038
|
if (String(row?.stripe_subscription_id || '').trim()) {
|
|
712
1039
|
try {
|
|
713
1040
|
await reconcileBillingFromStripe(core, userId, row?.stripe_subscription_id);
|
|
@@ -924,12 +1251,23 @@ export function registerBillingRoutes(deps) {
|
|
|
924
1251
|
return;
|
|
925
1252
|
}
|
|
926
1253
|
try {
|
|
1254
|
+
maintainCheckoutAttempts(core);
|
|
927
1255
|
const userId = requestUserId(req);
|
|
928
1256
|
const db = core.getDatabaseAdapter();
|
|
929
1257
|
const tenantId = core.getTenantId();
|
|
930
|
-
const
|
|
931
|
-
const row = billingReconciliation.row;
|
|
1258
|
+
const row = getBillingMirrorRow(core, userId);
|
|
932
1259
|
const access = core.getAccountAccessStatus(userId);
|
|
1260
|
+
const billingConflictCode = resolveBillingConflictCode(core, {
|
|
1261
|
+
userId,
|
|
1262
|
+
gate: String(access.gate || '')
|
|
1263
|
+
});
|
|
1264
|
+
const resolvedPlanId = String(access.entitlement?.planId || row?.tier || '').trim() || null;
|
|
1265
|
+
const planRow = resolvedPlanId ? db.prepare(`
|
|
1266
|
+
SELECT display_name
|
|
1267
|
+
FROM plan_catalog
|
|
1268
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
1269
|
+
LIMIT 1
|
|
1270
|
+
`).get(tenantId, resolvedPlanId) : undefined;
|
|
933
1271
|
if (!access.hasEntitlement || !access.entitlement) {
|
|
934
1272
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
935
1273
|
res.end(JSON.stringify({
|
|
@@ -941,24 +1279,18 @@ export function registerBillingRoutes(deps) {
|
|
|
941
1279
|
trialEnd: row?.trial_end || null,
|
|
942
1280
|
currentPeriodEnd: row?.current_period_end || null,
|
|
943
1281
|
effectiveUntil: row?.effective_until || null,
|
|
944
|
-
entitlementState: null,
|
|
1282
|
+
entitlementState: row?.entitlement_state || null,
|
|
945
1283
|
planVersionId: null,
|
|
946
|
-
planId:
|
|
947
|
-
planName:
|
|
1284
|
+
planId: resolvedPlanId,
|
|
1285
|
+
planName: planRow?.display_name ? String(planRow.display_name) : resolvedPlanId,
|
|
948
1286
|
gate: access.gate,
|
|
949
1287
|
message: access.message,
|
|
950
1288
|
planSelectionRequired: access.planSelectionRequired,
|
|
951
|
-
billingConflictCode
|
|
1289
|
+
billingConflictCode
|
|
952
1290
|
}));
|
|
953
1291
|
return;
|
|
954
1292
|
}
|
|
955
1293
|
const entitlement = access.entitlement;
|
|
956
|
-
const planRow = db.prepare(`
|
|
957
|
-
SELECT display_name
|
|
958
|
-
FROM plan_catalog
|
|
959
|
-
WHERE tenant_id = ? AND plan_id = ?
|
|
960
|
-
LIMIT 1
|
|
961
|
-
`).get(tenantId, entitlement.planId);
|
|
962
1294
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
963
1295
|
res.end(JSON.stringify({
|
|
964
1296
|
stripeStatus: row?.stripe_status || null,
|
|
@@ -976,7 +1308,7 @@ export function registerBillingRoutes(deps) {
|
|
|
976
1308
|
gate: access.gate,
|
|
977
1309
|
message: access.message,
|
|
978
1310
|
planSelectionRequired: access.planSelectionRequired,
|
|
979
|
-
billingConflictCode
|
|
1311
|
+
billingConflictCode
|
|
980
1312
|
}));
|
|
981
1313
|
}
|
|
982
1314
|
catch (error) {
|
|
@@ -1280,6 +1612,68 @@ export function registerBillingRoutes(deps) {
|
|
|
1280
1612
|
rows
|
|
1281
1613
|
}));
|
|
1282
1614
|
});
|
|
1615
|
+
addRoute('GET', '/api/taskforce/admin/billing/checkout-attempts', async (req, res) => {
|
|
1616
|
+
if (!ensureCloudBilling(req, res))
|
|
1617
|
+
return;
|
|
1618
|
+
if (!ensureAdminRole(req)) {
|
|
1619
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1620
|
+
res.end(JSON.stringify({ error: 'Forbidden: system admin role required.' }));
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
const db = core.getDatabaseAdapter();
|
|
1624
|
+
const tenantId = core.getTenantId();
|
|
1625
|
+
const requestUrl = parseRequestUrl(req);
|
|
1626
|
+
const limit = parsePositiveIntBounded(requestUrl.searchParams.get('limit'), 50, 1, 200);
|
|
1627
|
+
const filterUserId = String(requestUrl.searchParams.get('userId') || '').trim();
|
|
1628
|
+
const now = new Date().toISOString();
|
|
1629
|
+
const maintenance = maintainCheckoutAttempts(core);
|
|
1630
|
+
const rows = filterUserId
|
|
1631
|
+
? db.prepare(`
|
|
1632
|
+
SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1633
|
+
FROM billing_checkout_attempts
|
|
1634
|
+
WHERE tenant_id = ? AND user_id = ?
|
|
1635
|
+
ORDER BY created_at DESC, session_id DESC
|
|
1636
|
+
LIMIT ?
|
|
1637
|
+
`).all(tenantId, filterUserId, limit)
|
|
1638
|
+
: db.prepare(`
|
|
1639
|
+
SELECT session_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1640
|
+
FROM billing_checkout_attempts
|
|
1641
|
+
WHERE tenant_id = ?
|
|
1642
|
+
ORDER BY created_at DESC, session_id DESC
|
|
1643
|
+
LIMIT ?
|
|
1644
|
+
`).all(tenantId, limit);
|
|
1645
|
+
const total = filterUserId
|
|
1646
|
+
? db.prepare(`
|
|
1647
|
+
SELECT COUNT(*) AS count
|
|
1648
|
+
FROM billing_checkout_attempts
|
|
1649
|
+
WHERE tenant_id = ? AND user_id = ?
|
|
1650
|
+
`).get(tenantId, filterUserId)
|
|
1651
|
+
: db.prepare(`
|
|
1652
|
+
SELECT COUNT(*) AS count
|
|
1653
|
+
FROM billing_checkout_attempts
|
|
1654
|
+
WHERE tenant_id = ?
|
|
1655
|
+
`).get(tenantId);
|
|
1656
|
+
const openCount = filterUserId
|
|
1657
|
+
? db.prepare(`
|
|
1658
|
+
SELECT COUNT(*) AS count
|
|
1659
|
+
FROM billing_checkout_attempts
|
|
1660
|
+
WHERE tenant_id = ? AND user_id = ? AND status = 'open'
|
|
1661
|
+
`).get(tenantId, filterUserId)
|
|
1662
|
+
: db.prepare(`
|
|
1663
|
+
SELECT COUNT(*) AS count
|
|
1664
|
+
FROM billing_checkout_attempts
|
|
1665
|
+
WHERE tenant_id = ? AND status = 'open'
|
|
1666
|
+
`).get(tenantId);
|
|
1667
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1668
|
+
res.end(JSON.stringify({
|
|
1669
|
+
asOf: now,
|
|
1670
|
+
expiredRowsNormalized: maintenance.expiredRows,
|
|
1671
|
+
purgedTerminalRows: maintenance.purgedRows,
|
|
1672
|
+
total: Number(total?.count || 0),
|
|
1673
|
+
openCount: Number(openCount?.count || 0),
|
|
1674
|
+
rows
|
|
1675
|
+
}));
|
|
1676
|
+
});
|
|
1283
1677
|
addRoute('POST', '/api/taskforce/admin/billing/prices/import', async (req, res) => {
|
|
1284
1678
|
if (!ensureCloudBilling(req, res))
|
|
1285
1679
|
return;
|
|
@@ -1378,6 +1772,7 @@ export function registerBillingRoutes(deps) {
|
|
|
1378
1772
|
return;
|
|
1379
1773
|
}
|
|
1380
1774
|
try {
|
|
1775
|
+
maintainCheckoutAttempts(core);
|
|
1381
1776
|
const userId = requestUserId(req);
|
|
1382
1777
|
const currentBillingResult = await reconcileBillingMirrorIfNeeded(core, userId);
|
|
1383
1778
|
const currentBilling = currentBillingResult.row;
|
|
@@ -1541,6 +1936,21 @@ export function registerBillingRoutes(deps) {
|
|
|
1541
1936
|
billingInterval: interval
|
|
1542
1937
|
}
|
|
1543
1938
|
});
|
|
1939
|
+
const checkoutEffectiveUntil = isoFromUnixSeconds(session.expires_at);
|
|
1940
|
+
recordCheckoutAttempt(core, {
|
|
1941
|
+
sessionId: session.id,
|
|
1942
|
+
userId,
|
|
1943
|
+
planVersionId,
|
|
1944
|
+
billingInterval: interval,
|
|
1945
|
+
expiresAt: checkoutEffectiveUntil
|
|
1946
|
+
});
|
|
1947
|
+
if (shouldRecordCheckoutPendingState(currentEntitlement) && !isCommercialStateAppAccessible(currentEntitlement?.entitlement_state)) {
|
|
1948
|
+
beginCheckoutPending(core, {
|
|
1949
|
+
userId,
|
|
1950
|
+
planVersionId,
|
|
1951
|
+
effectiveUntil: checkoutEffectiveUntil
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1544
1954
|
const checkoutUrl = isMockStripeClient(stripe)
|
|
1545
1955
|
? buildMockStripeHostedCheckoutUrl(resolveHostedServerBaseUrl(req), session.id)
|
|
1546
1956
|
: (session.url || null);
|
|
@@ -1582,11 +1992,30 @@ export function registerBillingRoutes(deps) {
|
|
|
1582
1992
|
return;
|
|
1583
1993
|
}
|
|
1584
1994
|
try {
|
|
1995
|
+
maintainCheckoutAttempts(core);
|
|
1585
1996
|
const stripe = getStripeClient();
|
|
1586
1997
|
const expectedUserId = requestUserId(req);
|
|
1587
1998
|
const session = await stripe.checkout.sessions.retrieve(sessionId, {
|
|
1588
1999
|
expand: ['subscription']
|
|
1589
2000
|
});
|
|
2001
|
+
const sessionStatus = String(session.status || '').trim().toLowerCase();
|
|
2002
|
+
const paymentStatus = String(session.payment_status || '').trim().toLowerCase();
|
|
2003
|
+
if (session.mode !== 'subscription') {
|
|
2004
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
2005
|
+
res.end(JSON.stringify({
|
|
2006
|
+
error: 'Checkout session is not a subscription checkout.',
|
|
2007
|
+
code: 'CHECKOUT_SESSION_INVALID_MODE'
|
|
2008
|
+
}));
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
if (sessionStatus !== 'complete' || (paymentStatus !== 'paid' && paymentStatus !== 'no_payment_required')) {
|
|
2012
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
2013
|
+
res.end(JSON.stringify({
|
|
2014
|
+
error: 'Checkout session has not completed yet.',
|
|
2015
|
+
code: 'CHECKOUT_SESSION_INCOMPLETE'
|
|
2016
|
+
}));
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
1590
2019
|
const metadata = session.metadata || {};
|
|
1591
2020
|
const customerId = String(session.customer || '').trim();
|
|
1592
2021
|
const subscriptionId = typeof session.subscription === 'string'
|
|
@@ -1605,22 +2034,47 @@ export function registerBillingRoutes(deps) {
|
|
|
1605
2034
|
res.end(JSON.stringify({ error: 'Checkout session does not belong to the current account.' }));
|
|
1606
2035
|
return;
|
|
1607
2036
|
}
|
|
2037
|
+
let resolvedPlanVersionId = null;
|
|
2038
|
+
let subscription = null;
|
|
2039
|
+
if (subscriptionId) {
|
|
2040
|
+
subscription = typeof session.subscription === 'string' || !session.subscription
|
|
2041
|
+
? await stripe.subscriptions.retrieve(subscriptionId)
|
|
2042
|
+
: session.subscription;
|
|
2043
|
+
const priceId = String(subscription.items?.data?.[0]?.price?.id || '').trim();
|
|
2044
|
+
resolvedPlanVersionId = resolvePlanVersionIdForBillingEvent(core, [
|
|
2045
|
+
subscription.metadata?.planVersionId,
|
|
2046
|
+
subscription.metadata?.plan_version_id,
|
|
2047
|
+
metadata.planVersionId,
|
|
2048
|
+
metadata.plan_version_id
|
|
2049
|
+
], priceId).planVersionId;
|
|
2050
|
+
const lifecycleGuard = resolveCheckoutLifecycleGuard(core, {
|
|
2051
|
+
userId: resolvedUserId,
|
|
2052
|
+
planVersionId: resolvedPlanVersionId,
|
|
2053
|
+
sessionId,
|
|
2054
|
+
subscriptionId
|
|
2055
|
+
});
|
|
2056
|
+
if (lifecycleGuard.blocked) {
|
|
2057
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
2058
|
+
res.end(JSON.stringify({
|
|
2059
|
+
error: lifecycleGuard.message,
|
|
2060
|
+
code: lifecycleGuard.code
|
|
2061
|
+
}));
|
|
2062
|
+
return;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
1608
2065
|
core.createUserBillingRecord(resolvedUserId);
|
|
1609
2066
|
upsertBillingMirror(core, {
|
|
1610
2067
|
userId: resolvedUserId,
|
|
1611
2068
|
stripeCustomerId: customerId || null,
|
|
1612
2069
|
stripeSubscriptionId: subscriptionId || null
|
|
1613
2070
|
});
|
|
1614
|
-
|
|
1615
|
-
if (session.mode === 'subscription' && subscriptionId) {
|
|
1616
|
-
const subscription = typeof session.subscription === 'string' || !session.subscription
|
|
1617
|
-
? await stripe.subscriptions.retrieve(subscriptionId)
|
|
1618
|
-
: session.subscription;
|
|
2071
|
+
if (subscription) {
|
|
1619
2072
|
resolvedPlanVersionId = applySubscriptionSnapshot(core, {
|
|
1620
2073
|
userId: resolvedUserId,
|
|
1621
2074
|
subscription,
|
|
1622
2075
|
source: 'checkout-confirm'
|
|
1623
2076
|
}).planVersionId;
|
|
2077
|
+
updateCheckoutAttemptStatus(core, sessionId, 'confirmed');
|
|
1624
2078
|
}
|
|
1625
2079
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1626
2080
|
res.end(JSON.stringify({
|
|
@@ -1791,6 +2245,7 @@ export function registerBillingRoutes(deps) {
|
|
|
1791
2245
|
const db = core.getDatabaseAdapter();
|
|
1792
2246
|
const tenantId = core.getTenantId();
|
|
1793
2247
|
purgeExpiredWebhookFailures(core);
|
|
2248
|
+
maintainCheckoutAttempts(core);
|
|
1794
2249
|
let duplicate = false;
|
|
1795
2250
|
const tx = db.transaction(() => {
|
|
1796
2251
|
const acquired = lockWebhookEvent(core, event);
|
|
@@ -1843,11 +2298,31 @@ export function registerBillingRoutes(deps) {
|
|
|
1843
2298
|
});
|
|
1844
2299
|
return;
|
|
1845
2300
|
}
|
|
1846
|
-
|
|
2301
|
+
const lifecycleGuard = resolveCheckoutLifecycleGuard(core, {
|
|
2302
|
+
userId,
|
|
2303
|
+
planVersionId,
|
|
2304
|
+
subscriptionId: String(subscription.id || '').trim()
|
|
2305
|
+
});
|
|
2306
|
+
if (lifecycleGuard.blocked) {
|
|
2307
|
+
recordWebhookFailure(core, {
|
|
2308
|
+
eventId: event.id,
|
|
2309
|
+
eventType: event.type,
|
|
2310
|
+
reason: lifecycleGuard.message,
|
|
2311
|
+
payload: event
|
|
2312
|
+
});
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
const snapshot = applySubscriptionSnapshot(core, {
|
|
1847
2316
|
userId,
|
|
1848
2317
|
subscription,
|
|
1849
2318
|
source: 'stripe-webhook'
|
|
1850
2319
|
});
|
|
2320
|
+
if (snapshot.canonicalState !== 'checkout_pending') {
|
|
2321
|
+
markLatestOpenCheckoutAttemptConfirmed(core, {
|
|
2322
|
+
userId,
|
|
2323
|
+
planVersionId: snapshot.planVersionId
|
|
2324
|
+
});
|
|
2325
|
+
}
|
|
1851
2326
|
return;
|
|
1852
2327
|
}
|
|
1853
2328
|
if (event.type === 'invoice.paid' || event.type === 'invoice.payment_failed') {
|
|
@@ -1868,6 +2343,20 @@ export function registerBillingRoutes(deps) {
|
|
|
1868
2343
|
invoice.subscription_details?.metadata?.plan_version_id
|
|
1869
2344
|
], priceId);
|
|
1870
2345
|
const planVersionId = planVersionResolution.planVersionId;
|
|
2346
|
+
const lifecycleGuard = resolveCheckoutLifecycleGuard(core, {
|
|
2347
|
+
userId,
|
|
2348
|
+
planVersionId,
|
|
2349
|
+
subscriptionId
|
|
2350
|
+
});
|
|
2351
|
+
if (lifecycleGuard.blocked) {
|
|
2352
|
+
recordWebhookFailure(core, {
|
|
2353
|
+
eventId: event.id,
|
|
2354
|
+
eventType: event.type,
|
|
2355
|
+
reason: lifecycleGuard.message,
|
|
2356
|
+
payload: event
|
|
2357
|
+
});
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
1871
2360
|
const existingEntitlement = getEntitlementRow(core, userId);
|
|
1872
2361
|
const canonicalState = event.type === 'invoice.payment_failed' ? 'grace' : 'active';
|
|
1873
2362
|
const currentPeriodEnd = invoice.lines?.data?.[0]?.period?.end
|
|
@@ -1903,6 +2392,10 @@ export function registerBillingRoutes(deps) {
|
|
|
1903
2392
|
effectiveAt: new Date().toISOString(),
|
|
1904
2393
|
effectiveUntil: currentPeriodEnd
|
|
1905
2394
|
});
|
|
2395
|
+
markLatestOpenCheckoutAttemptConfirmed(core, {
|
|
2396
|
+
userId,
|
|
2397
|
+
planVersionId
|
|
2398
|
+
});
|
|
1906
2399
|
return;
|
|
1907
2400
|
}
|
|
1908
2401
|
});
|