@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
|
@@ -5,8 +5,20 @@ import { registerBillingRoutes } from './billing';
|
|
|
5
5
|
const mockConstructEvent = vi.fn();
|
|
6
6
|
const mockPricesList = vi.fn(async () => ({ data: [], has_more: false }));
|
|
7
7
|
const mockPriceRetrieve = vi.fn(async (id) => ({ id, active: true, currency: 'usd', unit_amount: 1500, recurring: { interval: 'month' } }));
|
|
8
|
-
const mockCheckoutCreate = vi.fn(async () => ({
|
|
9
|
-
|
|
8
|
+
const mockCheckoutCreate = vi.fn(async () => ({
|
|
9
|
+
id: 'cs_test',
|
|
10
|
+
url: 'https://example.test/checkout',
|
|
11
|
+
expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
|
|
12
|
+
}));
|
|
13
|
+
const mockCheckoutRetrieve = vi.fn(async (id) => ({
|
|
14
|
+
id,
|
|
15
|
+
mode: 'subscription',
|
|
16
|
+
status: 'complete',
|
|
17
|
+
payment_status: 'paid',
|
|
18
|
+
customer: 'cus_123',
|
|
19
|
+
subscription: 'sub_123',
|
|
20
|
+
metadata: { userId: 'u1' }
|
|
21
|
+
}));
|
|
10
22
|
const mockPortalCreate = vi.fn(async () => ({ id: 'bps_test', url: 'https://example.test/portal' }));
|
|
11
23
|
const mockSubscriptionCancel = vi.fn(async () => ({ id: 'sub_test', status: 'canceled' }));
|
|
12
24
|
const mockSubscriptionList = vi.fn(async () => ({ data: [] }));
|
|
@@ -130,6 +142,17 @@ function setupBillingSchema(db) {
|
|
|
130
142
|
purge_after TEXT NOT NULL,
|
|
131
143
|
created_at TEXT NOT NULL
|
|
132
144
|
);
|
|
145
|
+
CREATE TABLE billing_checkout_attempts (
|
|
146
|
+
session_id TEXT PRIMARY KEY,
|
|
147
|
+
tenant_id TEXT NOT NULL,
|
|
148
|
+
user_id TEXT NOT NULL,
|
|
149
|
+
plan_version_id TEXT NOT NULL,
|
|
150
|
+
billing_interval TEXT,
|
|
151
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
152
|
+
expires_at TEXT,
|
|
153
|
+
created_at TEXT NOT NULL,
|
|
154
|
+
updated_at TEXT NOT NULL
|
|
155
|
+
);
|
|
133
156
|
CREATE TABLE billing_price_mappings (
|
|
134
157
|
tenant_id TEXT NOT NULL,
|
|
135
158
|
plan_version_id TEXT NOT NULL,
|
|
@@ -228,23 +251,25 @@ function buildBillingRouteHandlers(db, opts) {
|
|
|
228
251
|
};
|
|
229
252
|
});
|
|
230
253
|
},
|
|
231
|
-
getAccountEntitlement: () =>
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
254
|
+
getAccountEntitlement: () => {
|
|
255
|
+
const entitlement = opts?.accessStatus?.entitlement;
|
|
256
|
+
return {
|
|
257
|
+
accountId: String(entitlement?.accountId || 'u1'),
|
|
258
|
+
planVersionId: String(entitlement?.planVersionId || 'pro-v1'),
|
|
259
|
+
planId: String(entitlement?.planId || 'pro'),
|
|
260
|
+
state: String(entitlement?.state || 'active'),
|
|
261
|
+
effectiveAt: entitlement?.effectiveAt ?? null,
|
|
262
|
+
effectiveUntil: entitlement?.effectiveUntil ?? null,
|
|
263
|
+
updatedBy: null,
|
|
264
|
+
createdAt: '',
|
|
265
|
+
updatedAt: ''
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
getAccountAccessStatus: () => opts?.accessStatus || ({
|
|
243
269
|
gate: 'ok',
|
|
244
270
|
hasEntitlement: true,
|
|
245
271
|
canAccessApp: true,
|
|
246
272
|
canAccessPlans: true,
|
|
247
|
-
signupMonetizationStrategy: 'auto_assign_default',
|
|
248
273
|
planSelectionRequired: false,
|
|
249
274
|
message: null,
|
|
250
275
|
entitlement: {
|
|
@@ -421,6 +446,286 @@ describe('Billing Webhook Routes', () => {
|
|
|
421
446
|
`).get();
|
|
422
447
|
expect(failure.reason).toContain('Unknown price mapping');
|
|
423
448
|
});
|
|
449
|
+
it('keeps blocked accounts in checkout_pending when Stripe reports an incomplete subscription', async () => {
|
|
450
|
+
const now = new Date().toISOString();
|
|
451
|
+
db.prepare(`
|
|
452
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
453
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
454
|
+
`).run(now, now);
|
|
455
|
+
db.prepare(`
|
|
456
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
457
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
458
|
+
`).run(now, now);
|
|
459
|
+
db.prepare(`
|
|
460
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
461
|
+
VALUES ('u1', 'default', 'cus_123', 'checkout_pending', ?, ?)
|
|
462
|
+
`).run(now, now);
|
|
463
|
+
db.prepare(`
|
|
464
|
+
INSERT INTO account_entitlements (
|
|
465
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
466
|
+
) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, 'checkout-start', ?, ?)
|
|
467
|
+
`).run(now, now, now);
|
|
468
|
+
mockConstructEvent.mockReturnValue({
|
|
469
|
+
id: 'evt_incomplete_subscription',
|
|
470
|
+
type: 'customer.subscription.updated',
|
|
471
|
+
data: {
|
|
472
|
+
object: {
|
|
473
|
+
id: 'sub_123',
|
|
474
|
+
customer: 'cus_123',
|
|
475
|
+
status: 'incomplete',
|
|
476
|
+
trial_end: null,
|
|
477
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
478
|
+
items: {
|
|
479
|
+
data: [{
|
|
480
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
481
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
482
|
+
}]
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
const req = createMockReq('{"id":"evt_incomplete_subscription"}', {
|
|
488
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
489
|
+
'stripe-signature': 'sig'
|
|
490
|
+
});
|
|
491
|
+
const res = createMockRes();
|
|
492
|
+
await webhookHandler(req, res, {});
|
|
493
|
+
expect(res.statusCode).toBe(200);
|
|
494
|
+
const entitlement = db.prepare(`
|
|
495
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
496
|
+
FROM account_entitlements
|
|
497
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
498
|
+
LIMIT 1
|
|
499
|
+
`).get();
|
|
500
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
501
|
+
plan_version_id: 'pro-v1',
|
|
502
|
+
entitlement_state: 'checkout_pending',
|
|
503
|
+
updated_by: 'stripe-webhook'
|
|
504
|
+
}));
|
|
505
|
+
const billing = db.prepare(`
|
|
506
|
+
SELECT entitlement_state, stripe_status, stripe_subscription_id
|
|
507
|
+
FROM user_billing
|
|
508
|
+
WHERE user_id = 'u1' AND tenant_id = 'default'
|
|
509
|
+
LIMIT 1
|
|
510
|
+
`).get();
|
|
511
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
512
|
+
entitlement_state: 'checkout_pending',
|
|
513
|
+
stripe_status: 'incomplete',
|
|
514
|
+
stripe_subscription_id: 'sub_123'
|
|
515
|
+
}));
|
|
516
|
+
});
|
|
517
|
+
it('preserves active access when Stripe reports an incomplete subscription for an already-active account', async () => {
|
|
518
|
+
const now = new Date().toISOString();
|
|
519
|
+
db.prepare(`
|
|
520
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
521
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
522
|
+
`).run(now, now);
|
|
523
|
+
db.prepare(`
|
|
524
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
525
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
526
|
+
`).run(now, now);
|
|
527
|
+
db.prepare(`
|
|
528
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
529
|
+
VALUES ('u1', 'default', 'cus_123', 'active', ?, ?)
|
|
530
|
+
`).run(now, now);
|
|
531
|
+
db.prepare(`
|
|
532
|
+
INSERT INTO account_entitlements (
|
|
533
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
534
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, 'stripe-webhook', ?, ?)
|
|
535
|
+
`).run(now, now, now);
|
|
536
|
+
mockConstructEvent.mockReturnValue({
|
|
537
|
+
id: 'evt_incomplete_active_account',
|
|
538
|
+
type: 'customer.subscription.updated',
|
|
539
|
+
data: {
|
|
540
|
+
object: {
|
|
541
|
+
id: 'sub_123',
|
|
542
|
+
customer: 'cus_123',
|
|
543
|
+
status: 'incomplete',
|
|
544
|
+
trial_end: null,
|
|
545
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
546
|
+
items: {
|
|
547
|
+
data: [{
|
|
548
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
549
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
550
|
+
}]
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
const req = createMockReq('{"id":"evt_incomplete_active_account"}', {
|
|
556
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
557
|
+
'stripe-signature': 'sig'
|
|
558
|
+
});
|
|
559
|
+
const res = createMockRes();
|
|
560
|
+
await webhookHandler(req, res, {});
|
|
561
|
+
expect(res.statusCode).toBe(200);
|
|
562
|
+
const entitlement = db.prepare(`
|
|
563
|
+
SELECT entitlement_state, updated_by
|
|
564
|
+
FROM account_entitlements
|
|
565
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
566
|
+
LIMIT 1
|
|
567
|
+
`).get();
|
|
568
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
569
|
+
entitlement_state: 'active',
|
|
570
|
+
updated_by: 'stripe-webhook'
|
|
571
|
+
}));
|
|
572
|
+
const billing = db.prepare(`
|
|
573
|
+
SELECT entitlement_state, stripe_status
|
|
574
|
+
FROM user_billing
|
|
575
|
+
WHERE user_id = 'u1' AND tenant_id = 'default'
|
|
576
|
+
LIMIT 1
|
|
577
|
+
`).get();
|
|
578
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
579
|
+
entitlement_state: 'active',
|
|
580
|
+
stripe_status: 'incomplete'
|
|
581
|
+
}));
|
|
582
|
+
});
|
|
583
|
+
it('ignores superseded subscription webhook events for a stale checkout plan selection', async () => {
|
|
584
|
+
const now = new Date().toISOString();
|
|
585
|
+
db.prepare(`
|
|
586
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
587
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
588
|
+
`).run(now, now);
|
|
589
|
+
db.prepare(`
|
|
590
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
591
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
592
|
+
`).run(now, now);
|
|
593
|
+
db.prepare(`
|
|
594
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
595
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
596
|
+
`).run(now, now);
|
|
597
|
+
db.prepare(`
|
|
598
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
599
|
+
VALUES ('u1', 'default', 'cus_123', 'checkout_pending', ?, ?)
|
|
600
|
+
`).run(now, now);
|
|
601
|
+
db.prepare(`
|
|
602
|
+
INSERT INTO account_entitlements (
|
|
603
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
|
|
604
|
+
) VALUES ('u1', 'default', 'team-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
|
|
605
|
+
`).run(now, new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
606
|
+
mockConstructEvent.mockReturnValue({
|
|
607
|
+
id: 'evt_stale_checkout_superseded',
|
|
608
|
+
type: 'customer.subscription.updated',
|
|
609
|
+
data: {
|
|
610
|
+
object: {
|
|
611
|
+
id: 'sub_123',
|
|
612
|
+
customer: 'cus_123',
|
|
613
|
+
status: 'active',
|
|
614
|
+
trial_end: null,
|
|
615
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
616
|
+
items: {
|
|
617
|
+
data: [{
|
|
618
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
619
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
620
|
+
}]
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
const req = createMockReq('{"id":"evt_stale_checkout_superseded"}', {
|
|
626
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
627
|
+
'stripe-signature': 'sig'
|
|
628
|
+
});
|
|
629
|
+
const res = createMockRes();
|
|
630
|
+
await webhookHandler(req, res, {});
|
|
631
|
+
expect(res.statusCode).toBe(200);
|
|
632
|
+
const entitlement = db.prepare(`
|
|
633
|
+
SELECT plan_version_id, entitlement_state
|
|
634
|
+
FROM account_entitlements
|
|
635
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
636
|
+
LIMIT 1
|
|
637
|
+
`).get();
|
|
638
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
639
|
+
plan_version_id: 'team-v1',
|
|
640
|
+
entitlement_state: 'checkout_pending'
|
|
641
|
+
}));
|
|
642
|
+
const failure = db.prepare(`
|
|
643
|
+
SELECT reason
|
|
644
|
+
FROM stripe_webhook_failures
|
|
645
|
+
WHERE event_id = 'evt_stale_checkout_superseded'
|
|
646
|
+
ORDER BY created_at DESC
|
|
647
|
+
LIMIT 1
|
|
648
|
+
`).get();
|
|
649
|
+
expect(String(failure?.reason || '')).toContain('no longer current for this account');
|
|
650
|
+
});
|
|
651
|
+
it('ignores stale subscription webhooks for active accounts when a newer checkout attempt is open', async () => {
|
|
652
|
+
const now = new Date().toISOString();
|
|
653
|
+
db.prepare(`
|
|
654
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
655
|
+
VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
|
|
656
|
+
`).run(now, now);
|
|
657
|
+
db.prepare(`
|
|
658
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
659
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
660
|
+
`).run(now, now);
|
|
661
|
+
db.prepare(`
|
|
662
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
663
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
664
|
+
`).run(now, now);
|
|
665
|
+
db.prepare(`
|
|
666
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
667
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
668
|
+
`).run(now, now);
|
|
669
|
+
db.prepare(`
|
|
670
|
+
INSERT INTO user_billing (
|
|
671
|
+
user_id, tenant_id, stripe_customer_id, tier, entitlement_state, created_at, updated_at
|
|
672
|
+
) VALUES ('u1', 'default', 'cus_123', 'starter', 'active', ?, ?)
|
|
673
|
+
`).run(now, now);
|
|
674
|
+
db.prepare(`
|
|
675
|
+
INSERT INTO account_entitlements (
|
|
676
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
677
|
+
) VALUES ('u1', 'default', 'starter-v1', 'active', ?, 'free-plan-selection', ?, ?)
|
|
678
|
+
`).run(now, now, now);
|
|
679
|
+
db.prepare(`
|
|
680
|
+
INSERT INTO billing_checkout_attempts (
|
|
681
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
682
|
+
) VALUES ('cs_newer', 'default', 'u1', 'team-v1', 'month', 'open', ?, ?, ?)
|
|
683
|
+
`).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
684
|
+
mockConstructEvent.mockReturnValue({
|
|
685
|
+
id: 'evt_active_stale_checkout',
|
|
686
|
+
type: 'customer.subscription.updated',
|
|
687
|
+
data: {
|
|
688
|
+
object: {
|
|
689
|
+
id: 'sub_stale',
|
|
690
|
+
customer: 'cus_123',
|
|
691
|
+
status: 'active',
|
|
692
|
+
trial_end: null,
|
|
693
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
694
|
+
items: {
|
|
695
|
+
data: [{
|
|
696
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
697
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
698
|
+
}]
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
const req = createMockReq('{"id":"evt_active_stale_checkout"}', {
|
|
704
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
705
|
+
'stripe-signature': 'sig'
|
|
706
|
+
});
|
|
707
|
+
const res = createMockRes();
|
|
708
|
+
await webhookHandler(req, res, {});
|
|
709
|
+
expect(res.statusCode).toBe(200);
|
|
710
|
+
const entitlement = db.prepare(`
|
|
711
|
+
SELECT plan_version_id, entitlement_state
|
|
712
|
+
FROM account_entitlements
|
|
713
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
714
|
+
LIMIT 1
|
|
715
|
+
`).get();
|
|
716
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
717
|
+
plan_version_id: 'starter-v1',
|
|
718
|
+
entitlement_state: 'active'
|
|
719
|
+
}));
|
|
720
|
+
const failure = db.prepare(`
|
|
721
|
+
SELECT reason
|
|
722
|
+
FROM stripe_webhook_failures
|
|
723
|
+
WHERE event_id = 'evt_active_stale_checkout'
|
|
724
|
+
ORDER BY created_at DESC
|
|
725
|
+
LIMIT 1
|
|
726
|
+
`).get();
|
|
727
|
+
expect(String(failure?.reason || '')).toContain('no longer current for this account');
|
|
728
|
+
});
|
|
424
729
|
it('purges expired webhook failures during webhook processing', async () => {
|
|
425
730
|
const past = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
|
426
731
|
const now = new Date().toISOString();
|
|
@@ -558,7 +863,7 @@ describe('Billing Webhook Routes', () => {
|
|
|
558
863
|
expect(args.customer).toBe('cus_existing');
|
|
559
864
|
expect(args.customer_email).toBeUndefined();
|
|
560
865
|
});
|
|
561
|
-
it('
|
|
866
|
+
it('records checkout_pending when a blocked account starts a paid checkout', async () => {
|
|
562
867
|
const now = new Date().toISOString();
|
|
563
868
|
db.prepare(`
|
|
564
869
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
@@ -568,10 +873,11 @@ describe('Billing Webhook Routes', () => {
|
|
|
568
873
|
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
569
874
|
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
570
875
|
`).run(now, now);
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
876
|
+
db.prepare(`
|
|
877
|
+
INSERT INTO account_entitlements (
|
|
878
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
879
|
+
) VALUES ('u1', 'default', 'starter-v1', 'pending_plan_selection', ?, ?, ?)
|
|
880
|
+
`).run(now, now, now);
|
|
575
881
|
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
576
882
|
const req = createMockReq(JSON.stringify({
|
|
577
883
|
planVersionId: 'collab-v1',
|
|
@@ -583,18 +889,186 @@ describe('Billing Webhook Routes', () => {
|
|
|
583
889
|
const res = createMockRes();
|
|
584
890
|
await checkoutHandler(req, res, {});
|
|
585
891
|
expect(res.statusCode).toBe(200);
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
`).
|
|
596
|
-
|
|
597
|
-
|
|
892
|
+
expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
|
|
893
|
+
checkoutState: 'checkout',
|
|
894
|
+
id: 'cs_test'
|
|
895
|
+
}));
|
|
896
|
+
const entitlement = db.prepare(`
|
|
897
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
898
|
+
FROM account_entitlements
|
|
899
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
900
|
+
LIMIT 1
|
|
901
|
+
`).get();
|
|
902
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
903
|
+
plan_version_id: 'collab-v1',
|
|
904
|
+
entitlement_state: 'checkout_pending',
|
|
905
|
+
updated_by: 'checkout-start'
|
|
906
|
+
}));
|
|
907
|
+
const billing = db.prepare(`
|
|
908
|
+
SELECT tier, entitlement_state, effective_until
|
|
909
|
+
FROM user_billing
|
|
910
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
911
|
+
LIMIT 1
|
|
912
|
+
`).get();
|
|
913
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
914
|
+
tier: 'collab',
|
|
915
|
+
entitlement_state: 'checkout_pending'
|
|
916
|
+
}));
|
|
917
|
+
expect(typeof billing?.effective_until).toBe('string');
|
|
918
|
+
});
|
|
919
|
+
it('does not downgrade an active account to checkout_pending when starting paid checkout', async () => {
|
|
920
|
+
const now = new Date().toISOString();
|
|
921
|
+
db.prepare(`
|
|
922
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
923
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
924
|
+
`).run(now, now);
|
|
925
|
+
db.prepare(`
|
|
926
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
927
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
928
|
+
`).run(now, now);
|
|
929
|
+
db.prepare(`
|
|
930
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
931
|
+
VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
|
|
932
|
+
`).run(now, now);
|
|
933
|
+
db.prepare(`
|
|
934
|
+
INSERT INTO account_entitlements (
|
|
935
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
936
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, 'stripe-webhook', ?, ?)
|
|
937
|
+
`).run(now, now, now);
|
|
938
|
+
db.prepare(`
|
|
939
|
+
INSERT INTO user_billing (
|
|
940
|
+
user_id, tenant_id, stripe_customer_id, tier, entitlement_state, created_at, updated_at
|
|
941
|
+
) VALUES ('u1', 'default', 'cus_existing', 'pro', 'active', ?, ?)
|
|
942
|
+
`).run(now, now);
|
|
943
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
944
|
+
const req = createMockReq(JSON.stringify({
|
|
945
|
+
planVersionId: 'team-v1',
|
|
946
|
+
interval: 'month'
|
|
947
|
+
}), {
|
|
948
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
949
|
+
host: 'app.taskforcehq.ai'
|
|
950
|
+
});
|
|
951
|
+
const res = createMockRes();
|
|
952
|
+
await checkoutHandler(req, res, {});
|
|
953
|
+
expect(res.statusCode).toBe(200);
|
|
954
|
+
const entitlement = db.prepare(`
|
|
955
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
956
|
+
FROM account_entitlements
|
|
957
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
958
|
+
LIMIT 1
|
|
959
|
+
`).get();
|
|
960
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
961
|
+
plan_version_id: 'pro-v1',
|
|
962
|
+
entitlement_state: 'active',
|
|
963
|
+
updated_by: 'stripe-webhook'
|
|
964
|
+
}));
|
|
965
|
+
const billing = db.prepare(`
|
|
966
|
+
SELECT tier, entitlement_state
|
|
967
|
+
FROM user_billing
|
|
968
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
969
|
+
LIMIT 1
|
|
970
|
+
`).get();
|
|
971
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
972
|
+
tier: 'pro',
|
|
973
|
+
entitlement_state: 'active'
|
|
974
|
+
}));
|
|
975
|
+
});
|
|
976
|
+
it('supersedes a stale checkout_pending selection when the user retries paid checkout', async () => {
|
|
977
|
+
const now = new Date().toISOString();
|
|
978
|
+
const previousExpiry = new Date(Date.now() - (60 * 60 * 1000)).toISOString();
|
|
979
|
+
db.prepare(`
|
|
980
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
981
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
982
|
+
`).run(now, now);
|
|
983
|
+
db.prepare(`
|
|
984
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
985
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
986
|
+
`).run(now, now);
|
|
987
|
+
db.prepare(`
|
|
988
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
989
|
+
VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
|
|
990
|
+
`).run(now, now);
|
|
991
|
+
db.prepare(`
|
|
992
|
+
INSERT INTO account_entitlements (
|
|
993
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
|
|
994
|
+
) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
|
|
995
|
+
`).run(now, previousExpiry, now, now);
|
|
996
|
+
db.prepare(`
|
|
997
|
+
INSERT INTO user_billing (
|
|
998
|
+
user_id, tenant_id, tier, entitlement_state, effective_until, created_at, updated_at
|
|
999
|
+
) VALUES ('u1', 'default', 'pro', 'checkout_pending', ?, ?, ?)
|
|
1000
|
+
`).run(previousExpiry, now, now);
|
|
1001
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
1002
|
+
const req = createMockReq(JSON.stringify({
|
|
1003
|
+
planVersionId: 'team-v1',
|
|
1004
|
+
interval: 'month'
|
|
1005
|
+
}), {
|
|
1006
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1007
|
+
host: 'app.taskforcehq.ai'
|
|
1008
|
+
});
|
|
1009
|
+
const res = createMockRes();
|
|
1010
|
+
await checkoutHandler(req, res, {});
|
|
1011
|
+
expect(res.statusCode).toBe(200);
|
|
1012
|
+
const entitlement = db.prepare(`
|
|
1013
|
+
SELECT plan_version_id, entitlement_state, effective_until
|
|
1014
|
+
FROM account_entitlements
|
|
1015
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1016
|
+
LIMIT 1
|
|
1017
|
+
`).get();
|
|
1018
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1019
|
+
plan_version_id: 'team-v1',
|
|
1020
|
+
entitlement_state: 'checkout_pending'
|
|
1021
|
+
}));
|
|
1022
|
+
expect(entitlement?.effective_until).not.toBe(previousExpiry);
|
|
1023
|
+
const billing = db.prepare(`
|
|
1024
|
+
SELECT tier, entitlement_state, effective_until
|
|
1025
|
+
FROM user_billing
|
|
1026
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
1027
|
+
LIMIT 1
|
|
1028
|
+
`).get();
|
|
1029
|
+
expect(billing).toEqual(expect.objectContaining({
|
|
1030
|
+
tier: 'team',
|
|
1031
|
+
entitlement_state: 'checkout_pending'
|
|
1032
|
+
}));
|
|
1033
|
+
expect(billing?.effective_until).not.toBe(previousExpiry);
|
|
1034
|
+
});
|
|
1035
|
+
it('reuses an existing Stripe customer discovered by account email before creating checkout', async () => {
|
|
1036
|
+
const now = new Date().toISOString();
|
|
1037
|
+
db.prepare(`
|
|
1038
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1039
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
1040
|
+
`).run(now, now);
|
|
1041
|
+
db.prepare(`
|
|
1042
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1043
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
1044
|
+
`).run(now, now);
|
|
1045
|
+
mockCustomersList.mockResolvedValueOnce({
|
|
1046
|
+
data: [{ id: 'cus_email_match' }]
|
|
1047
|
+
});
|
|
1048
|
+
mockSubscriptionList.mockResolvedValueOnce({ data: [] });
|
|
1049
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
1050
|
+
const req = createMockReq(JSON.stringify({
|
|
1051
|
+
planVersionId: 'collab-v1',
|
|
1052
|
+
interval: 'month'
|
|
1053
|
+
}), {
|
|
1054
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1055
|
+
host: 'app.taskforcehq.ai'
|
|
1056
|
+
});
|
|
1057
|
+
const res = createMockRes();
|
|
1058
|
+
await checkoutHandler(req, res, {});
|
|
1059
|
+
expect(res.statusCode).toBe(200);
|
|
1060
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
1061
|
+
expect(args.customer).toBe('cus_email_match');
|
|
1062
|
+
expect(args.customer_email).toBeUndefined();
|
|
1063
|
+
});
|
|
1064
|
+
it('builds checkout return URLs from explicit local proxy origin headers', async () => {
|
|
1065
|
+
const now = new Date().toISOString();
|
|
1066
|
+
db.prepare(`
|
|
1067
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1068
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
1069
|
+
`).run(now, now);
|
|
1070
|
+
db.prepare(`
|
|
1071
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
598
1072
|
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
599
1073
|
`).run(now, now);
|
|
600
1074
|
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
@@ -1063,6 +1537,282 @@ describe('Billing Webhook Routes', () => {
|
|
|
1063
1537
|
entitlement_state: 'active'
|
|
1064
1538
|
}));
|
|
1065
1539
|
});
|
|
1540
|
+
it('does not activate entitlement when the checkout session is still incomplete', async () => {
|
|
1541
|
+
const now = new Date().toISOString();
|
|
1542
|
+
db.prepare(`
|
|
1543
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1544
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1545
|
+
`).run(now, now);
|
|
1546
|
+
db.prepare(`
|
|
1547
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1548
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1549
|
+
`).run(now, now);
|
|
1550
|
+
db.prepare(`
|
|
1551
|
+
INSERT INTO account_entitlements (
|
|
1552
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
1553
|
+
) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, 'checkout-start', ?, ?)
|
|
1554
|
+
`).run(now, now, now);
|
|
1555
|
+
mockCheckoutRetrieve.mockResolvedValueOnce({
|
|
1556
|
+
id: 'cs_open_123',
|
|
1557
|
+
mode: 'subscription',
|
|
1558
|
+
status: 'open',
|
|
1559
|
+
payment_status: 'unpaid',
|
|
1560
|
+
customer: 'cus_123',
|
|
1561
|
+
subscription: 'sub_123',
|
|
1562
|
+
metadata: { userId: 'u1' }
|
|
1563
|
+
});
|
|
1564
|
+
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1565
|
+
const req = createMockReq(JSON.stringify({
|
|
1566
|
+
sessionId: 'cs_open_123'
|
|
1567
|
+
}), {
|
|
1568
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1569
|
+
host: 'app.taskforcehq.ai'
|
|
1570
|
+
});
|
|
1571
|
+
const res = createMockRes();
|
|
1572
|
+
await confirmHandler(req, res, {});
|
|
1573
|
+
expect(res.statusCode).toBe(409);
|
|
1574
|
+
expect(JSON.parse(res.body)).toEqual({
|
|
1575
|
+
error: 'Checkout session has not completed yet.',
|
|
1576
|
+
code: 'CHECKOUT_SESSION_INCOMPLETE'
|
|
1577
|
+
});
|
|
1578
|
+
expect(mockSubscriptionRetrieve).not.toHaveBeenCalledWith('sub_123');
|
|
1579
|
+
const entitlement = db.prepare(`
|
|
1580
|
+
SELECT plan_version_id, entitlement_state, updated_by
|
|
1581
|
+
FROM account_entitlements
|
|
1582
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1583
|
+
LIMIT 1
|
|
1584
|
+
`).get();
|
|
1585
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1586
|
+
plan_version_id: 'pro-v1',
|
|
1587
|
+
entitlement_state: 'checkout_pending',
|
|
1588
|
+
updated_by: 'checkout-start'
|
|
1589
|
+
}));
|
|
1590
|
+
});
|
|
1591
|
+
it('rejects checkout confirmation when a newer pending checkout superseded the session plan', async () => {
|
|
1592
|
+
const now = new Date().toISOString();
|
|
1593
|
+
db.prepare(`
|
|
1594
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1595
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1596
|
+
`).run(now, now);
|
|
1597
|
+
db.prepare(`
|
|
1598
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1599
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1600
|
+
`).run(now, now);
|
|
1601
|
+
db.prepare(`
|
|
1602
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1603
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1604
|
+
`).run(now, now);
|
|
1605
|
+
db.prepare(`
|
|
1606
|
+
INSERT INTO account_entitlements (
|
|
1607
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
|
|
1608
|
+
) VALUES ('u1', 'default', 'team-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
|
|
1609
|
+
`).run(now, new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
1610
|
+
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1611
|
+
const req = createMockReq(JSON.stringify({
|
|
1612
|
+
sessionId: 'cs_stale_123'
|
|
1613
|
+
}), {
|
|
1614
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1615
|
+
host: 'app.taskforcehq.ai'
|
|
1616
|
+
});
|
|
1617
|
+
const res = createMockRes();
|
|
1618
|
+
await confirmHandler(req, res, {});
|
|
1619
|
+
expect(res.statusCode).toBe(409);
|
|
1620
|
+
expect(JSON.parse(res.body)).toEqual({
|
|
1621
|
+
error: 'This checkout session is no longer current for this account.',
|
|
1622
|
+
code: 'CHECKOUT_SESSION_SUPERSEDED'
|
|
1623
|
+
});
|
|
1624
|
+
const entitlement = db.prepare(`
|
|
1625
|
+
SELECT plan_version_id, entitlement_state
|
|
1626
|
+
FROM account_entitlements
|
|
1627
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1628
|
+
LIMIT 1
|
|
1629
|
+
`).get();
|
|
1630
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1631
|
+
plan_version_id: 'team-v1',
|
|
1632
|
+
entitlement_state: 'checkout_pending'
|
|
1633
|
+
}));
|
|
1634
|
+
});
|
|
1635
|
+
it('records the latest active-account checkout attempt and rejects confirmation for a superseded session', async () => {
|
|
1636
|
+
const now = new Date().toISOString();
|
|
1637
|
+
db.prepare(`
|
|
1638
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1639
|
+
VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
|
|
1640
|
+
`).run(now, now);
|
|
1641
|
+
db.prepare(`
|
|
1642
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1643
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1644
|
+
`).run(now, now);
|
|
1645
|
+
db.prepare(`
|
|
1646
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1647
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1648
|
+
`).run(now, now);
|
|
1649
|
+
db.prepare(`
|
|
1650
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1651
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1652
|
+
`).run(now, now);
|
|
1653
|
+
db.prepare(`
|
|
1654
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1655
|
+
VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
|
|
1656
|
+
`).run(now, now);
|
|
1657
|
+
db.prepare(`
|
|
1658
|
+
INSERT INTO user_billing (
|
|
1659
|
+
user_id, tenant_id, tier, entitlement_state, created_at, updated_at
|
|
1660
|
+
) VALUES ('u1', 'default', 'starter', 'active', ?, ?)
|
|
1661
|
+
`).run(now, now);
|
|
1662
|
+
db.prepare(`
|
|
1663
|
+
INSERT INTO account_entitlements (
|
|
1664
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
1665
|
+
) VALUES ('u1', 'default', 'starter-v1', 'active', ?, 'free-plan-selection', ?, ?)
|
|
1666
|
+
`).run(now, now, now);
|
|
1667
|
+
mockCheckoutCreate
|
|
1668
|
+
.mockResolvedValueOnce({
|
|
1669
|
+
id: 'cs_old',
|
|
1670
|
+
url: 'https://example.test/checkout/old',
|
|
1671
|
+
expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
|
|
1672
|
+
})
|
|
1673
|
+
.mockResolvedValueOnce({
|
|
1674
|
+
id: 'cs_new',
|
|
1675
|
+
url: 'https://example.test/checkout/new',
|
|
1676
|
+
expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
|
|
1677
|
+
});
|
|
1678
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
1679
|
+
const firstReq = createMockReq(JSON.stringify({
|
|
1680
|
+
planVersionId: 'pro-v1',
|
|
1681
|
+
interval: 'month'
|
|
1682
|
+
}), {
|
|
1683
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1684
|
+
host: 'app.taskforcehq.ai'
|
|
1685
|
+
});
|
|
1686
|
+
const firstRes = createMockRes();
|
|
1687
|
+
await checkoutHandler(firstReq, firstRes, {});
|
|
1688
|
+
expect(firstRes.statusCode).toBe(200);
|
|
1689
|
+
const secondReq = createMockReq(JSON.stringify({
|
|
1690
|
+
planVersionId: 'team-v1',
|
|
1691
|
+
interval: 'month'
|
|
1692
|
+
}), {
|
|
1693
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1694
|
+
host: 'app.taskforcehq.ai'
|
|
1695
|
+
});
|
|
1696
|
+
const secondRes = createMockRes();
|
|
1697
|
+
await checkoutHandler(secondReq, secondRes, {});
|
|
1698
|
+
expect(secondRes.statusCode).toBe(200);
|
|
1699
|
+
const attempts = db.prepare(`
|
|
1700
|
+
SELECT session_id, plan_version_id, status
|
|
1701
|
+
FROM billing_checkout_attempts
|
|
1702
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
1703
|
+
ORDER BY session_id ASC
|
|
1704
|
+
`).all();
|
|
1705
|
+
expect(attempts).toEqual([
|
|
1706
|
+
{ session_id: 'cs_new', plan_version_id: 'team-v1', status: 'open' },
|
|
1707
|
+
{ session_id: 'cs_old', plan_version_id: 'pro-v1', status: 'superseded' }
|
|
1708
|
+
]);
|
|
1709
|
+
mockCheckoutRetrieve.mockResolvedValueOnce({
|
|
1710
|
+
id: 'cs_old',
|
|
1711
|
+
mode: 'subscription',
|
|
1712
|
+
status: 'complete',
|
|
1713
|
+
payment_status: 'paid',
|
|
1714
|
+
customer: 'cus_123',
|
|
1715
|
+
subscription: 'sub_old',
|
|
1716
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' }
|
|
1717
|
+
});
|
|
1718
|
+
mockSubscriptionRetrieve.mockResolvedValueOnce({
|
|
1719
|
+
id: 'sub_old',
|
|
1720
|
+
customer: 'cus_123',
|
|
1721
|
+
status: 'active',
|
|
1722
|
+
trial_end: null,
|
|
1723
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' },
|
|
1724
|
+
items: {
|
|
1725
|
+
data: [{
|
|
1726
|
+
id: 'si_old',
|
|
1727
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
1728
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
1729
|
+
}]
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1733
|
+
const confirmReq = createMockReq(JSON.stringify({
|
|
1734
|
+
sessionId: 'cs_old'
|
|
1735
|
+
}), {
|
|
1736
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1737
|
+
host: 'app.taskforcehq.ai'
|
|
1738
|
+
});
|
|
1739
|
+
const confirmRes = createMockRes();
|
|
1740
|
+
await confirmHandler(confirmReq, confirmRes, {});
|
|
1741
|
+
expect(confirmRes.statusCode).toBe(409);
|
|
1742
|
+
expect(JSON.parse(confirmRes.body)).toEqual({
|
|
1743
|
+
error: 'This checkout session is no longer current for this account.',
|
|
1744
|
+
code: 'CHECKOUT_SESSION_SUPERSEDED'
|
|
1745
|
+
});
|
|
1746
|
+
});
|
|
1747
|
+
it('ignores subscription webhooks for a stale subscription after a newer checkout already won', async () => {
|
|
1748
|
+
const now = new Date().toISOString();
|
|
1749
|
+
db.prepare(`
|
|
1750
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1751
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1752
|
+
`).run(now, now);
|
|
1753
|
+
db.prepare(`
|
|
1754
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1755
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1756
|
+
`).run(now, now);
|
|
1757
|
+
db.prepare(`
|
|
1758
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1759
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1760
|
+
`).run(now, now);
|
|
1761
|
+
db.prepare(`
|
|
1762
|
+
INSERT INTO user_billing (
|
|
1763
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, tier, entitlement_state, created_at, updated_at
|
|
1764
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_current', 'team', 'active', ?, ?)
|
|
1765
|
+
`).run(now, now);
|
|
1766
|
+
db.prepare(`
|
|
1767
|
+
INSERT INTO account_entitlements (
|
|
1768
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
|
|
1769
|
+
) VALUES ('u1', 'default', 'team-v1', 'active', ?, 'checkout-confirm', ?, ?)
|
|
1770
|
+
`).run(now, now, now);
|
|
1771
|
+
mockConstructEvent.mockReturnValue({
|
|
1772
|
+
id: 'evt_stale_subscription_after_confirm',
|
|
1773
|
+
type: 'customer.subscription.updated',
|
|
1774
|
+
data: {
|
|
1775
|
+
object: {
|
|
1776
|
+
id: 'sub_old',
|
|
1777
|
+
customer: 'cus_123',
|
|
1778
|
+
status: 'active',
|
|
1779
|
+
trial_end: null,
|
|
1780
|
+
metadata: { planVersionId: 'pro-v1' },
|
|
1781
|
+
items: {
|
|
1782
|
+
data: [{
|
|
1783
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
1784
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
1785
|
+
}]
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
const req = createMockReq('{"id":"evt_stale_subscription_after_confirm"}', {
|
|
1791
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1792
|
+
'stripe-signature': 'sig'
|
|
1793
|
+
});
|
|
1794
|
+
const res = createMockRes();
|
|
1795
|
+
await webhookHandler(req, res, {});
|
|
1796
|
+
expect(res.statusCode).toBe(200);
|
|
1797
|
+
const entitlement = db.prepare(`
|
|
1798
|
+
SELECT plan_version_id, entitlement_state
|
|
1799
|
+
FROM account_entitlements
|
|
1800
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
1801
|
+
LIMIT 1
|
|
1802
|
+
`).get();
|
|
1803
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1804
|
+
plan_version_id: 'team-v1',
|
|
1805
|
+
entitlement_state: 'active'
|
|
1806
|
+
}));
|
|
1807
|
+
const failure = db.prepare(`
|
|
1808
|
+
SELECT reason
|
|
1809
|
+
FROM stripe_webhook_failures
|
|
1810
|
+
WHERE event_id = 'evt_stale_subscription_after_confirm'
|
|
1811
|
+
ORDER BY created_at DESC
|
|
1812
|
+
LIMIT 1
|
|
1813
|
+
`).get();
|
|
1814
|
+
expect(String(failure?.reason || '')).toContain('no longer current for this account');
|
|
1815
|
+
});
|
|
1066
1816
|
it('lists webhook failure diagnostics for system admin', async () => {
|
|
1067
1817
|
const now = new Date().toISOString();
|
|
1068
1818
|
const old = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
|
@@ -1092,6 +1842,49 @@ describe('Billing Webhook Routes', () => {
|
|
|
1092
1842
|
reason: 'diagnostic-1'
|
|
1093
1843
|
}));
|
|
1094
1844
|
});
|
|
1845
|
+
it('lists checkout attempt diagnostics for system admin and purges old terminal rows', async () => {
|
|
1846
|
+
const now = new Date().toISOString();
|
|
1847
|
+
const stale = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString();
|
|
1848
|
+
db.prepare(`
|
|
1849
|
+
INSERT INTO billing_checkout_attempts (
|
|
1850
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1851
|
+
) VALUES ('cs_keep_open', 'default', 'u1', 'team-v1', 'month', 'open', ?, ?, ?)
|
|
1852
|
+
`).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
|
|
1853
|
+
db.prepare(`
|
|
1854
|
+
INSERT INTO billing_checkout_attempts (
|
|
1855
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1856
|
+
) VALUES ('cs_purge_old', 'default', 'u1', 'pro-v1', 'month', 'superseded', ?, ?, ?)
|
|
1857
|
+
`).run(stale, stale, stale);
|
|
1858
|
+
db.prepare(`
|
|
1859
|
+
INSERT INTO billing_checkout_attempts (
|
|
1860
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
1861
|
+
) VALUES ('cs_expire_now', 'default', 'u2', 'pro-v1', 'month', 'open', ?, ?, ?)
|
|
1862
|
+
`).run(new Date(Date.now() - 60 * 1000).toISOString(), now, now);
|
|
1863
|
+
const route = routeHandlers.get('GET /api/taskforce/admin/billing/checkout-attempts');
|
|
1864
|
+
expect(route).toBeTypeOf('function');
|
|
1865
|
+
const req = createMockReq('', {
|
|
1866
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
1867
|
+
});
|
|
1868
|
+
req.url = '/api/taskforce/admin/billing/checkout-attempts?limit=10';
|
|
1869
|
+
const res = createMockRes();
|
|
1870
|
+
await route(req, res, {});
|
|
1871
|
+
expect(res.statusCode).toBe(200);
|
|
1872
|
+
const payload = JSON.parse(res.body);
|
|
1873
|
+
expect(payload.purgedTerminalRows).toBeGreaterThanOrEqual(1);
|
|
1874
|
+
expect(payload.expiredRowsNormalized).toBeGreaterThanOrEqual(1);
|
|
1875
|
+
expect(payload.openCount).toBe(1);
|
|
1876
|
+
expect(payload.rows).toEqual(expect.arrayContaining([
|
|
1877
|
+
expect.objectContaining({ session_id: 'cs_keep_open', status: 'open' }),
|
|
1878
|
+
expect.objectContaining({ session_id: 'cs_expire_now', status: 'expired' })
|
|
1879
|
+
]));
|
|
1880
|
+
const purged = db.prepare(`
|
|
1881
|
+
SELECT session_id
|
|
1882
|
+
FROM billing_checkout_attempts
|
|
1883
|
+
WHERE session_id = 'cs_purge_old'
|
|
1884
|
+
LIMIT 1
|
|
1885
|
+
`).get();
|
|
1886
|
+
expect(purged).toBeUndefined();
|
|
1887
|
+
});
|
|
1095
1888
|
it('lists stripe price options filtered by interval for system admin', async () => {
|
|
1096
1889
|
mockPricesList.mockResolvedValueOnce({
|
|
1097
1890
|
data: [
|
|
@@ -1329,6 +2122,18 @@ describe('Billing Webhook Routes', () => {
|
|
|
1329
2122
|
await route(req, res, {});
|
|
1330
2123
|
expect(res.statusCode).toBe(403);
|
|
1331
2124
|
});
|
|
2125
|
+
it('rejects checkout attempt diagnostics for non-admin', async () => {
|
|
2126
|
+
const nonAdminHandlers = buildBillingRouteHandlers(db, { isAdmin: false });
|
|
2127
|
+
const route = nonAdminHandlers.get('GET /api/taskforce/admin/billing/checkout-attempts');
|
|
2128
|
+
expect(route).toBeTypeOf('function');
|
|
2129
|
+
const req = createMockReq('', {
|
|
2130
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
2131
|
+
});
|
|
2132
|
+
req.url = '/api/taskforce/admin/billing/checkout-attempts';
|
|
2133
|
+
const res = createMockRes();
|
|
2134
|
+
await route(req, res, {});
|
|
2135
|
+
expect(res.statusCode).toBe(403);
|
|
2136
|
+
});
|
|
1332
2137
|
it('returns public pricing catalog with cached Stripe amounts without auth', async () => {
|
|
1333
2138
|
const now = new Date().toISOString();
|
|
1334
2139
|
routeHandlers = buildBillingRouteHandlers(db);
|
|
@@ -1433,12 +2238,100 @@ describe('Billing Webhook Routes', () => {
|
|
|
1433
2238
|
expect(body.planVersionId).toBe('pro-v1');
|
|
1434
2239
|
expect(body.entitlementState).toBe('active');
|
|
1435
2240
|
});
|
|
1436
|
-
it('
|
|
2241
|
+
it('prefers canonical checkout_pending state over stale billing mirror state in billing status payload', async () => {
|
|
2242
|
+
routeHandlers = buildBillingRouteHandlers(db, {
|
|
2243
|
+
accessStatus: {
|
|
2244
|
+
gate: 'checkout_pending',
|
|
2245
|
+
hasEntitlement: true,
|
|
2246
|
+
canAccessApp: false,
|
|
2247
|
+
canAccessPlans: true,
|
|
2248
|
+
planSelectionRequired: false,
|
|
2249
|
+
message: 'Complete checkout to continue.',
|
|
2250
|
+
entitlement: {
|
|
2251
|
+
accountId: 'u1',
|
|
2252
|
+
planVersionId: 'pro-v1',
|
|
2253
|
+
planId: 'pro',
|
|
2254
|
+
state: 'checkout_pending',
|
|
2255
|
+
effectiveAt: null,
|
|
2256
|
+
effectiveUntil: null
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
});
|
|
1437
2260
|
const now = new Date().toISOString();
|
|
1438
2261
|
db.prepare(`
|
|
1439
2262
|
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
1440
2263
|
VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
|
|
1441
2264
|
`).run(now, now);
|
|
2265
|
+
db.prepare(`
|
|
2266
|
+
INSERT INTO user_billing (
|
|
2267
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
|
|
2268
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'starter', 'month', 'active', ?, ?)
|
|
2269
|
+
`).run(now, now);
|
|
2270
|
+
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
2271
|
+
expect(statusRoute).toBeTypeOf('function');
|
|
2272
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
2273
|
+
const res = createMockRes();
|
|
2274
|
+
await statusRoute(req, res, {});
|
|
2275
|
+
expect(res.statusCode).toBe(200);
|
|
2276
|
+
const body = JSON.parse(res.body);
|
|
2277
|
+
expect(body).toEqual(expect.objectContaining({
|
|
2278
|
+
planId: 'pro',
|
|
2279
|
+
planName: 'Pro Plan',
|
|
2280
|
+
planVersionId: 'pro-v1',
|
|
2281
|
+
entitlementState: 'checkout_pending',
|
|
2282
|
+
gate: 'checkout_pending',
|
|
2283
|
+
message: 'Complete checkout to continue.',
|
|
2284
|
+
planSelectionRequired: false,
|
|
2285
|
+
billingConflictCode: null,
|
|
2286
|
+
stripeStatus: 'active',
|
|
2287
|
+
stripeCustomerId: 'cus_123',
|
|
2288
|
+
stripeSubscriptionId: 'sub_123'
|
|
2289
|
+
}));
|
|
2290
|
+
});
|
|
2291
|
+
it('returns checkout conflict code in billing status when the latest attempt expired', async () => {
|
|
2292
|
+
routeHandlers = buildBillingRouteHandlers(db, {
|
|
2293
|
+
accessStatus: {
|
|
2294
|
+
gate: 'plan_selection_required',
|
|
2295
|
+
hasEntitlement: true,
|
|
2296
|
+
canAccessApp: false,
|
|
2297
|
+
canAccessPlans: true,
|
|
2298
|
+
planSelectionRequired: true,
|
|
2299
|
+
message: 'Choose a plan to continue.',
|
|
2300
|
+
entitlement: {
|
|
2301
|
+
accountId: 'u1',
|
|
2302
|
+
planVersionId: 'pro-v1',
|
|
2303
|
+
planId: 'pro',
|
|
2304
|
+
state: 'pending_plan_selection',
|
|
2305
|
+
effectiveAt: null,
|
|
2306
|
+
effectiveUntil: null
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
});
|
|
2310
|
+
const now = new Date().toISOString();
|
|
2311
|
+
db.prepare(`
|
|
2312
|
+
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
2313
|
+
VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
|
|
2314
|
+
`).run(now, now);
|
|
2315
|
+
db.prepare(`
|
|
2316
|
+
INSERT INTO billing_checkout_attempts (
|
|
2317
|
+
session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
|
|
2318
|
+
) VALUES ('cs_expired_status', 'default', 'u1', 'pro-v1', 'month', 'expired', ?, ?, ?)
|
|
2319
|
+
`).run(new Date(Date.now() - 60 * 1000).toISOString(), now, now);
|
|
2320
|
+
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
2321
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
2322
|
+
const res = createMockRes();
|
|
2323
|
+
await statusRoute(req, res, {});
|
|
2324
|
+
expect(res.statusCode).toBe(200);
|
|
2325
|
+
const body = JSON.parse(res.body);
|
|
2326
|
+
expect(body.billingConflictCode).toBe('CHECKOUT_SESSION_EXPIRED');
|
|
2327
|
+
});
|
|
2328
|
+
it('does not reconcile stale billing state from Stripe when billing status is requested', async () => {
|
|
2329
|
+
const now = new Date().toISOString();
|
|
2330
|
+
const stale = new Date(Date.now() - 60_000).toISOString();
|
|
2331
|
+
db.prepare(`
|
|
2332
|
+
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
2333
|
+
VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
|
|
2334
|
+
`).run(now, now);
|
|
1442
2335
|
db.prepare(`
|
|
1443
2336
|
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1444
2337
|
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
@@ -1451,27 +2344,54 @@ describe('Billing Webhook Routes', () => {
|
|
|
1451
2344
|
INSERT INTO user_billing (
|
|
1452
2345
|
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
|
|
1453
2346
|
) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_old', 'active', 'starter', 'month', 'active', ?, ?)
|
|
2347
|
+
`).run(stale, stale);
|
|
2348
|
+
db.prepare(`
|
|
2349
|
+
INSERT INTO account_entitlements (
|
|
2350
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
2351
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
|
|
2352
|
+
`).run(stale, stale, stale);
|
|
2353
|
+
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
2354
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
2355
|
+
const res = createMockRes();
|
|
2356
|
+
await statusRoute(req, res, {});
|
|
2357
|
+
expect(res.statusCode).toBe(200);
|
|
2358
|
+
expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
|
|
2359
|
+
expect(mockCustomersList).not.toHaveBeenCalled();
|
|
2360
|
+
expect(mockSubscriptionList).not.toHaveBeenCalled();
|
|
2361
|
+
const body = JSON.parse(res.body);
|
|
2362
|
+
expect(body.planVersionId).toBe('pro-v1');
|
|
2363
|
+
expect(body.planName).toBe('Pro Plan');
|
|
2364
|
+
});
|
|
2365
|
+
it('skips Stripe reconciliation for fresh billing mirror rows when billing status is requested', async () => {
|
|
2366
|
+
const now = new Date().toISOString();
|
|
2367
|
+
db.prepare(`
|
|
2368
|
+
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
2369
|
+
VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
|
|
2370
|
+
`).run(now, now);
|
|
2371
|
+
db.prepare(`
|
|
2372
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
2373
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
2374
|
+
`).run(now, now);
|
|
2375
|
+
db.prepare(`
|
|
2376
|
+
INSERT INTO user_billing (
|
|
2377
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
|
|
2378
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
|
|
1454
2379
|
`).run(now, now);
|
|
1455
2380
|
db.prepare(`
|
|
1456
2381
|
INSERT INTO account_entitlements (
|
|
1457
2382
|
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
1458
|
-
) VALUES ('u1', 'default', '
|
|
2383
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
|
|
1459
2384
|
`).run(now, now, now);
|
|
1460
2385
|
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
1461
2386
|
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1462
2387
|
const res = createMockRes();
|
|
1463
2388
|
await statusRoute(req, res, {});
|
|
1464
2389
|
expect(res.statusCode).toBe(200);
|
|
1465
|
-
expect(mockSubscriptionRetrieve).
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
FROM account_entitlements
|
|
1469
|
-
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1470
|
-
LIMIT 1
|
|
1471
|
-
`).get();
|
|
1472
|
-
expect(entitlement?.plan_version_id).toBe('pro-v1');
|
|
2390
|
+
expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
|
|
2391
|
+
expect(mockCustomersList).not.toHaveBeenCalled();
|
|
2392
|
+
expect(mockSubscriptionList).not.toHaveBeenCalled();
|
|
1473
2393
|
});
|
|
1474
|
-
it('
|
|
2394
|
+
it('does not discover Stripe subscriptions by account email when billing status is requested', async () => {
|
|
1475
2395
|
const now = new Date().toISOString();
|
|
1476
2396
|
db.prepare(`
|
|
1477
2397
|
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
@@ -1509,11 +2429,13 @@ describe('Billing Webhook Routes', () => {
|
|
|
1509
2429
|
const res = createMockRes();
|
|
1510
2430
|
await statusRoute(req, res, {});
|
|
1511
2431
|
expect(res.statusCode).toBe(200);
|
|
2432
|
+
expect(mockCustomersList).not.toHaveBeenCalled();
|
|
2433
|
+
expect(mockSubscriptionList).not.toHaveBeenCalled();
|
|
1512
2434
|
const body = JSON.parse(res.body);
|
|
1513
2435
|
expect(body.planId).toBe('pro');
|
|
1514
2436
|
expect(body.planVersionId).toBe('pro-v1');
|
|
1515
|
-
expect(body.stripeCustomerId).
|
|
1516
|
-
expect(body.stripeSubscriptionId).
|
|
2437
|
+
expect(body.stripeCustomerId).toBeNull();
|
|
2438
|
+
expect(body.stripeSubscriptionId).toBeNull();
|
|
1517
2439
|
});
|
|
1518
2440
|
it('prefers subscription metadata over shared price fallback during webhook processing', async () => {
|
|
1519
2441
|
const now = new Date().toISOString();
|