added ai chat for existing pricing

This commit is contained in:
Ayush Rodrigues
2026-02-02 14:28:11 +00:00
committed by Charlie Lamb
parent 49b500b1b5
commit 9e074ead99
37 changed files with 2500 additions and 1100 deletions

192
.cursor/debug.log Normal file
View File

@@ -0,0 +1,192 @@
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199265,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038199265,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199268,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199268,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199268,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038199268,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038212315,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038212315,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212315,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038212315,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A,B"}
{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"}
{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038253473,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038255476,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038255476,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038255477,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038255477,"sessionId":"debug-session","hypothesisId":"D,E"}
{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038255477,"sessionId":"debug-session","hypothesisId":"E"}
{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038255477,"sessionId":"debug-session","hypothesisId":"D,E"}

View File

@@ -99,23 +99,34 @@ export const handleSyncPreviewPricing = createRoute({
features: [] as Awaited<ReturnType<typeof FeatureService.list>>,
};
// Create features
await Promise.all(
body.features.map((apiFeature) => {
const dbFeature = apiFeatureToDbFeature({ apiFeature });
return createFeature({
ctx: previewCtx,
data: {
id: dbFeature.id,
name: dbFeature.name,
type: dbFeature.type,
config: dbFeature.config,
event_names: dbFeature.event_names,
},
skipGenerateDisplay: true,
});
}),
);
// Deduplicate features by ID (keep first occurrence)
const seenFeatureIds = new Set<string>();
const uniqueFeatures = body.features.filter((f) => {
if (seenFeatureIds.has(f.id)) {
ctx.logger.warn(
`[Preview Sync] Duplicate feature ID found: ${f.id}, skipping...`,
);
return false;
}
seenFeatureIds.add(f.id);
return true;
});
// Create features sequentially to avoid race conditions
for (const apiFeature of uniqueFeatures) {
const dbFeature = apiFeatureToDbFeature({ apiFeature });
await createFeature({
ctx: previewCtx,
data: {
id: dbFeature.id,
name: dbFeature.name,
type: dbFeature.type,
config: dbFeature.config,
event_names: dbFeature.event_names,
},
skipGenerateDisplay: true,
});
}
// Get updated features for product creation
const updatedFeatures = await FeatureService.list({

View File

@@ -1,13 +1,13 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { InternalError } from "@autumn/shared";
import { type AgentPricingConfig, InternalError } from "@autumn/shared";
import { withTracing } from "@posthog/ai";
import { convertToModelMessages, streamText, type UIMessage } from "ai";
import { Hono } from "hono";
import { PostHog } from "posthog-node";
import { z } from "zod/v4";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { handleSetupPreviewOrg } from "./handlers/handleSetupPreviewOrg.js";
import { handleSyncPreviewPricing } from "./handlers/handleSyncPreviewPricing.js";
import { OrganisationConfigurationSchema } from "./pricingAgentSchemas.js";
// PostHog client singleton
let phClient: PostHog | null = null;
@@ -23,226 +23,6 @@ const getPostHogClient = (): PostHog | null => {
return phClient;
};
// ============ SCHEMAS ============
const ApiFeatureType = z.enum([
"static",
"boolean",
"single_use",
"continuous_use",
"credit_system",
]);
const ProductItemInterval = z.enum([
"minute",
"hour",
"day",
"week",
"month",
"quarter",
"semi_annual",
"year",
]);
const UsageModel = z.enum(["prepaid", "pay_per_use"]);
const FreeTrialDuration = z.enum(["day", "month", "year"]);
const FeatureSchema = z
.object({
id: z
.string()
.describe(
"Unique ID for the feature (lowercase, underscores, no spaces)",
),
name: z.string().describe("Display name for the feature"),
type: ApiFeatureType.describe(
"Type: single_use for consumables, continuous_use for allocated resources, boolean for on/off",
),
display: z
.object({
singular: z
.string()
.describe(
"Singular form of the unit (e.g., 'message', 'credit', 'seat', 'API call')",
),
plural: z
.string()
.describe(
"Plural form of the unit (e.g., 'messages', 'credits', 'seats', 'API calls')",
),
})
.describe(
"REQUIRED for metered features (single_use, continuous_use, credit_system). Used for display like '100 messages' or '1 seat'.",
),
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
)
.nullish(),
})
.refine(
(data) => {
if (data.type === "credit_system") {
return data.credit_schema && data.credit_schema.length > 0;
}
return true;
},
{
message:
"Credit system features require at least one metered feature in credit_schema.",
path: ["credit_schema"],
},
);
const PriceTierSchema = z.object({
to: z
.number()
.or(z.literal("inf"))
.describe("The upper limit of this tier (use 'inf' for unlimited)"),
amount: z.number().describe("The price per unit for this tier"),
});
const ProductItemSchema = z.object({
feature_id: z
.string()
.nullish()
.describe(
"Feature ID this item relates to. Set to null for standalone flat-fee price items (e.g., subscription base price, one-time purchase price).",
),
included_usage: z
.number()
.or(z.literal("inf"))
.nullish()
.describe(
"Usage granted to the customer. Use WITHOUT price for free allocations. Use WITH usage_model and price for metered pricing.",
),
interval: ProductItemInterval.nullish().describe("Reset/billing interval"),
price: z
.number()
.nullish()
.describe(
"Price amount. When feature_id is null, this is a standalone flat fee. When feature_id is set with usage_model, this is the per-unit price.",
),
tiers: z
.array(PriceTierSchema)
.nullish()
.describe(
"Tiered pricing structure. Use instead of price for volume-based pricing. Each tier defines upper limit (to) and price per unit (amount).",
),
usage_model: UsageModel.nullish().describe(
"prepaid or pay_per_use. Required when pricing per unit of usage.",
),
billing_units: z
.number()
.nullish()
.describe("Units per price (e.g., $1 per 30 credits)"),
});
const FreeTrialSchema = z
.object({
length: z.number().describe("Length of free trial"),
duration: FreeTrialDuration.describe("Unit: day, month, or year"),
unique_fingerprint: z.boolean().default(false),
card_required: z.boolean().default(true),
})
.nullish();
const ProductSchema = z
.object({
id: z.string().describe("Unique ID (lowercase, hyphens allowed)"),
name: z.string().describe("Display name"),
is_add_on: z
.boolean()
.default(false)
.describe(
"Set to true if this product is an add-on or top-up, (can be purchased together with other base plans).",
),
is_default: z
.boolean()
.default(false)
.describe(
"Set to true ONLY if the items array is completely empty OR contains only items with price: null. ANY pricing items (including pay-per-use, overage charges, prepaid etc.) disqualifies a plan from being default.",
),
group: z
.string()
.default("")
.describe(
"A group to assign this plan to. Leave empty unless user is building pricing where a customer could subscribe to 2 or more types of plans at the same time.`",
),
items: z.array(ProductItemSchema).default([]),
free_trial: FreeTrialSchema,
})
.refine(
(data) => {
if (data.is_default) {
return data.items.every((item) => item.price == null);
}
return true;
},
{
message:
"Default plans cannot have priced items. All items must have price: null or undefined.",
path: ["is_default"],
},
)
.refine(
(data) => {
const usageBasedFeatureIds = new Set(
data.items
.filter((item) => item.feature_id != null && item.usage_model != null)
.map((item) => item.feature_id),
);
// Check if any other items reference the same feature_id
return !data.items.some(
(item) =>
item.feature_id != null &&
item.usage_model == null &&
usageBasedFeatureIds.has(item.feature_id),
);
},
{
message:
"Cannot have separate items for the same feature when one has usage-based pricing. Combine into a single item (e.g., 100 free, then $0.10 per additional).",
path: ["items"],
},
)
.refine(
(data) => {
return !data.items.some(
(item) => item.usage_model === "pay_per_use" && item.interval == null,
);
},
{
message:
"Pay-per-use pricing requires an interval. Set interval (e.g., 'month') for usage-based items.",
path: ["items"],
},
)
.refine(
(data) => {
return !data.items.some(
(item) =>
item.price != null &&
item.feature_id != null &&
item.usage_model == null,
);
},
{
message:
"Priced metered features require a usage_model. Set to 'pay_per_use' or 'prepaid'.",
path: ["items"],
},
);
const OrganisationConfigurationSchema = z.object({
features: z.array(FeatureSchema).default([]),
products: z.array(ProductSchema),
});
type PricingConfig = z.infer<typeof OrganisationConfigurationSchema>;
// ============ SYSTEM PROMPT ============
const SYSTEM_PROMPT = `You are a helpful pricing configuration assistant for Autumn, a billing and entitlements platform.
@@ -331,8 +111,15 @@ This creates: $Y/month base price that includes 1 unit, then $Y per additional u
export const pricingAgentRouter = new Hono<HonoEnv>();
pricingAgentRouter.post("/chat", async (c) => {
const { messages, sessionId }: { messages: UIMessage[]; sessionId?: string } =
await c.req.json();
const {
messages,
sessionId,
initialConfig,
}: {
messages: UIMessage[];
sessionId?: string;
initialConfig?: AgentPricingConfig | null;
} = await c.req.json();
const ctx = c.var.ctx;
if (!process.env.ANTHROPIC_API_KEY) {
@@ -342,6 +129,25 @@ pricingAgentRouter.post("/chat", async (c) => {
});
}
// Build system prompt, optionally including initial config context
let systemPrompt = SYSTEM_PROMPT;
if (
initialConfig &&
(initialConfig.products.length > 0 || initialConfig.features.length > 0)
) {
systemPrompt += `
## Current Pricing Configuration
The user has an existing pricing setup that they want to modify. Here is their current configuration:
\`\`\`json
${JSON.stringify(initialConfig, null, 2)}
\`\`\`
When the user asks to make changes, modify this existing configuration rather than starting from scratch. Build upon what they already have unless they explicitly ask to start fresh.`;
}
// Create Anthropic client and optionally wrap with PostHog tracing
const anthropicClient = createAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
@@ -366,7 +172,7 @@ pricingAgentRouter.post("/chat", async (c) => {
const result = streamText({
model,
system: SYSTEM_PROMPT,
system: systemPrompt,
messages: await convertToModelMessages(messages),
tools: {
build_pricing: {

View File

@@ -0,0 +1,262 @@
import { z } from "zod/v4";
// ============ VALIDATION HELPERS ============
const validateOneDefaultPerGroup = ({
products,
}: {
products: {
is_default: boolean;
group: string;
free_trial?: { card_required: boolean } | null;
}[];
}): boolean => {
const productsByGroup = new Map<string, typeof products>();
for (const product of products) {
const group = product.group || "";
if (!productsByGroup.has(group)) {
productsByGroup.set(group, []);
}
productsByGroup.get(group)!.push(product);
}
for (const [_, groupProducts] of productsByGroup) {
// Count products with is_default that DON'T have card-not-required free trials
const defaultWithoutCardlessTrialCount = groupProducts.filter(
(p) =>
p.is_default && !(p.free_trial && p.free_trial.card_required === false),
).length;
if (defaultWithoutCardlessTrialCount > 1) {
return false;
}
}
return true;
};
// ============ SCHEMAS ============
const ApiFeatureType = z.enum([
"static",
"boolean",
"single_use",
"continuous_use",
"credit_system",
]);
const ProductItemInterval = z.enum([
"minute",
"hour",
"day",
"week",
"month",
"quarter",
"semi_annual",
"year",
]);
const UsageModel = z.enum(["prepaid", "pay_per_use"]);
const FreeTrialDuration = z.enum(["day", "month", "year"]);
const FeatureSchema = z
.object({
id: z
.string()
.describe(
"Unique ID for the feature (lowercase, underscores, no spaces)",
),
name: z.string().describe("Display name for the feature"),
type: ApiFeatureType.describe(
"Type: single_use for consumables, continuous_use for allocated resources, boolean for on/off",
),
display: z
.object({
singular: z
.string()
.describe(
"Singular form of the unit (e.g., 'message', 'credit', 'seat', 'API call')",
),
plural: z
.string()
.describe(
"Plural form of the unit (e.g., 'messages', 'credits', 'seats', 'API calls')",
),
})
.describe(
"REQUIRED for metered features (single_use, continuous_use, credit_system). Used for display like '100 messages' or '1 seat'.",
),
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
)
.nullish(),
})
.refine(
(data) => {
if (data.type === "credit_system") {
return data.credit_schema && data.credit_schema.length > 0;
}
return true;
},
{
message:
"Credit system features require at least one metered feature in credit_schema.",
path: ["credit_schema"],
},
);
const PriceTierSchema = z.object({
to: z
.number()
.or(z.literal("inf"))
.describe("The upper limit of this tier (use 'inf' for unlimited)"),
amount: z.number().describe("The price per unit for this tier"),
});
const ProductItemSchema = z.object({
feature_id: z
.string()
.nullish()
.describe(
"Feature ID this item relates to. Set to null for standalone flat-fee price items (e.g., subscription base price, one-time purchase price).",
),
included_usage: z
.number()
.or(z.literal("inf"))
.nullish()
.describe(
"Usage granted to the customer. Use WITHOUT price for free allocations. Use WITH usage_model and price for metered pricing.",
),
interval: ProductItemInterval.nullish().describe("Reset/billing interval"),
price: z
.number()
.nullish()
.describe(
"Price amount. When feature_id is null, this is a standalone flat fee. When feature_id is set with usage_model, this is the per-unit price.",
),
tiers: z
.array(PriceTierSchema)
.nullish()
.describe(
"Tiered pricing structure. Use instead of price for volume-based pricing. Each tier defines upper limit (to) and price per unit (amount).",
),
usage_model: UsageModel.nullish().describe(
"prepaid or pay_per_use. Required when pricing per unit of usage.",
),
billing_units: z
.number()
.nullish()
.describe("Units per price (e.g., $1 per 30 credits)"),
});
const FreeTrialSchema = z
.object({
length: z.number().describe("Length of free trial"),
duration: FreeTrialDuration.describe("Unit: day, month, or year"),
unique_fingerprint: z.boolean().default(false),
card_required: z.boolean().default(true),
})
.nullish();
const ProductSchema = z
.object({
id: z.string().describe("Unique ID (lowercase, hyphens allowed)"),
name: z.string().describe("Display name"),
is_add_on: z
.boolean()
.default(false)
.describe(
"Set to true if this product is an add-on or top-up, (can be purchased together with other base plans).",
),
is_default: z
.boolean()
.default(false)
.describe(
"Set to true ONLY if the items array is completely empty OR contains only items with price: null. ANY pricing items (including pay-per-use, overage charges, prepaid etc.) disqualifies a plan from being default.",
),
group: z
.string()
.default("")
.describe(
"A group to assign this plan to. Leave empty unless user is building pricing where a customer could subscribe to 2 or more types of plans at the same time.`",
),
items: z.array(ProductItemSchema).default([]),
free_trial: FreeTrialSchema,
})
.refine(
(data) => {
if (data.is_default) {
return data.items.every((item) => item.price == null);
}
return true;
},
{
message:
"Default plans cannot have priced items. All items must have price: null or undefined.",
path: ["is_default"],
},
)
.refine(
(data) => {
const usageBasedFeatureIds = new Set(
data.items
.filter((item) => item.feature_id != null && item.usage_model != null)
.map((item) => item.feature_id),
);
// Check if any other items reference the same feature_id
return !data.items.some(
(item) =>
item.feature_id != null &&
item.usage_model == null &&
usageBasedFeatureIds.has(item.feature_id),
);
},
{
message:
"Cannot have separate items for the same feature when one has usage-based pricing. Combine into a single item (e.g., 100 free, then $0.10 per additional).",
path: ["items"],
},
)
.refine(
(data) => {
return !data.items.some(
(item) => item.usage_model === "pay_per_use" && item.interval == null,
);
},
{
message:
"Pay-per-use pricing requires an interval. Set interval (e.g., 'month') for usage-based items.",
path: ["items"],
},
)
.refine(
(data) => {
return !data.items.some(
(item) =>
item.price != null &&
item.feature_id != null &&
item.usage_model == null,
);
},
{
message:
"Priced metered features require a usage_model. Set to 'pay_per_use' or 'prepaid'.",
path: ["items"],
},
);
export const OrganisationConfigurationSchema = z
.object({
features: z.array(FeatureSchema).default([]),
products: z.array(ProductSchema),
})
.refine((data) => validateOneDefaultPerGroup({ products: data.products }), {
message:
"Only one plan per group can have is_default: true, unless it also has a free trial with card_required: false.",
path: ["products"],
});
export type PricingConfig = z.infer<typeof OrganisationConfigurationSchema>;

View File

@@ -172,6 +172,8 @@ export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js"
export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js";
export * from "./models/subModels/subModels.js";
export * from "./models/subModels/subTable.js";
// Agent Types (for pricing agent AI)
export * from "./utils/agentTypes.js";
export * from "./utils/billingUtils/index.js";
// Checkout Utils
export * from "./utils/checkoutUtils/index.js";

311
shared/utils/agentTypes.ts Normal file
View File

@@ -0,0 +1,311 @@
/**
* Agent Types - Types and converters for the AI pricing agent
*
* The "Agent" format is a simplified, AI-friendly format used by the pricing agent.
* It uses string literals like "single_use" instead of enums, making it easier for
* LLMs to generate and for users to read.
*
* This module provides:
* - TypeScript interfaces for the agent format
* - Converters: AgentFeature ↔ Feature, AgentProduct ↔ ProductV2
*/
import {
FeatureType,
FeatureUsageType,
} from "../models/featureModels/featureEnums.js";
import type { Feature } from "../models/featureModels/featureModels.js";
import { AppEnv } from "../models/genModels/genEnums.js";
import { Infinite } from "../models/productModels/productEnums.js";
import type { ProductItem } from "../models/productV2Models/productItemModels/productItemModels.js";
import type { ProductV2 } from "../models/productV2Models/productV2Models.js";
// ============ INTERFACES ============
export type AgentFeatureType =
| "static"
| "boolean"
| "single_use"
| "continuous_use"
| "credit_system";
export interface AgentFeature {
id: string;
name?: string | null;
type: AgentFeatureType;
display?: {
singular: string;
plural: string;
} | null;
credit_schema?: Array<{
metered_feature_id: string;
credit_cost: number;
}> | null;
}
export interface AgentProductItem {
feature_id?: string | null;
included_usage?: number | "inf" | null;
interval?: string | null;
price?: number | null;
tiers?: Array<{ to: number | "inf"; amount: number }> | null;
usage_model?: "prepaid" | "pay_per_use" | null;
billing_units?: number | null;
}
export interface AgentFreeTrial {
length: number;
duration: "day" | "month" | "year";
unique_fingerprint?: boolean;
card_required?: boolean;
}
export interface AgentProduct {
id: string;
name: string;
is_add_on?: boolean;
is_default?: boolean;
group?: string;
items?: AgentProductItem[];
free_trial?: AgentFreeTrial | null;
}
export interface AgentPricingConfig {
features: AgentFeature[];
products: AgentProduct[];
}
// ============ AGENT → SHARED CONVERTERS ============
function mapAgentTypeToFeatureType(agentType: AgentFeatureType): FeatureType {
switch (agentType) {
case "boolean":
case "static":
return FeatureType.Boolean;
case "credit_system":
return FeatureType.CreditSystem;
default:
return FeatureType.Metered;
}
}
function mapAgentTypeToUsageType(
agentType: AgentFeatureType,
): FeatureUsageType | null {
switch (agentType) {
case "single_use":
return FeatureUsageType.Single;
case "continuous_use":
return FeatureUsageType.Continuous;
default:
return null;
}
}
/** Convert AgentFeature → Feature (shared DB type) */
export function agentFeatureToFeature(agentFeature: AgentFeature): Feature {
const usageType = mapAgentTypeToUsageType(agentFeature.type);
const config: Record<string, unknown> = {};
if (usageType) {
config.usage_type = usageType;
}
if (agentFeature.credit_schema) {
config.schema = agentFeature.credit_schema.map((s) => ({
metered_feature_id: s.metered_feature_id,
credit_amount: s.credit_cost,
}));
}
return {
internal_id: agentFeature.id,
org_id: "",
created_at: Date.now(),
env: AppEnv.Sandbox,
id: agentFeature.id,
name: agentFeature.name ?? agentFeature.display?.plural ?? agentFeature.id,
type: mapAgentTypeToFeatureType(agentFeature.type),
config: Object.keys(config).length > 0 ? config : null,
display: agentFeature.display ?? undefined,
archived: false,
event_names: [],
};
}
/** Convert AgentProductItem → ProductItem (shared DB type) */
export function agentItemToProductItem(item: AgentProductItem): ProductItem {
return {
feature_id: item.feature_id ?? undefined,
included_usage:
item.included_usage === "inf"
? Infinite
: (item.included_usage ?? undefined),
interval: item.interval as ProductItem["interval"],
price: item.price ?? undefined,
billing_units: item.billing_units ?? undefined,
usage_model: item.usage_model as ProductItem["usage_model"],
tiers: item.tiers?.map((t) => ({
to: t.to === "inf" ? Infinite : t.to,
amount: t.amount,
})),
};
}
/** Convert AgentProduct → ProductV2 (shared DB type) */
export function agentProductToProductV2(product: AgentProduct): ProductV2 {
return {
internal_id: product.id,
id: product.id,
name: product.name,
description: null,
is_add_on: product.is_add_on ?? false,
is_default: product.is_default ?? false,
version: 1,
group: product.group ?? null,
env: AppEnv.Sandbox,
free_trial: null, // Handled separately in preview transformations
items: (product.items ?? []).map(agentItemToProductItem),
created_at: Date.now(),
};
}
// ============ SHARED → AGENT CONVERTERS ============
function mapFeatureTypeToAgentType(feature: Feature): AgentFeatureType {
if (feature.type === FeatureType.CreditSystem) {
return "credit_system";
}
if (feature.type === FeatureType.Boolean) {
return "boolean";
}
if (feature.type === FeatureType.Metered) {
const usageType = feature.config?.usage_type;
if (
usageType === "continuous_use" ||
usageType === FeatureUsageType.Continuous
) {
return "continuous_use";
}
return "single_use";
}
return "static";
}
/** Convert Feature → AgentFeature */
export function featureToAgentFeature(feature: Feature): AgentFeature {
const agentFeature: AgentFeature = {
id: feature.id,
name: feature.name,
type: mapFeatureTypeToAgentType(feature),
};
if (feature.display?.singular || feature.display?.plural) {
agentFeature.display = {
singular: feature.display.singular ?? feature.name,
plural: feature.display.plural ?? feature.name,
};
}
if (feature.type === FeatureType.CreditSystem && feature.config?.schema) {
agentFeature.credit_schema = feature.config.schema.map(
(s: { metered_feature_id: string; credit_amount: number }) => ({
metered_feature_id: s.metered_feature_id,
credit_cost: s.credit_amount,
}),
);
}
return agentFeature;
}
/** Convert ProductItem → AgentProductItem */
export function productItemToAgentItem(item: ProductItem): AgentProductItem {
const agentItem: AgentProductItem = {};
if (item.feature_id) {
agentItem.feature_id = item.feature_id;
}
if (item.included_usage !== undefined && item.included_usage !== null) {
agentItem.included_usage =
item.included_usage === Infinite ? "inf" : item.included_usage;
}
if (item.interval) {
agentItem.interval = item.interval;
}
if (item.price !== undefined && item.price !== null) {
agentItem.price = item.price;
}
// Copy tiers if present (tiered pricing)
if (item.tiers && item.tiers.length > 0) {
agentItem.tiers = item.tiers.map((t) => ({
to: t.to === Infinite ? "inf" : t.to,
amount: t.amount,
}));
}
if (item.usage_model) {
agentItem.usage_model = item.usage_model as "prepaid" | "pay_per_use";
}
if (item.billing_units) {
agentItem.billing_units = item.billing_units;
}
return agentItem;
}
/** Convert ProductV2 → AgentProduct */
export function productV2ToAgentProduct(product: ProductV2): AgentProduct {
const agentProduct: AgentProduct = {
id: product.id,
name: product.name,
};
if (product.is_add_on) {
agentProduct.is_add_on = true;
}
if (product.is_default) {
agentProduct.is_default = true;
}
if (product.group) {
agentProduct.group = product.group;
}
if (product.items && product.items.length > 0) {
agentProduct.items = product.items.map(productItemToAgentItem);
}
if (product.free_trial) {
agentProduct.free_trial = {
length: product.free_trial.length,
duration: product.free_trial.duration as "day" | "month" | "year",
unique_fingerprint: product.free_trial.unique_fingerprint,
card_required: product.free_trial.card_required,
};
}
return agentProduct;
}
/** Convert ProductV2[] and Feature[] → AgentPricingConfig */
export function convertToAgentConfig({
products,
features,
}: {
products: ProductV2[];
features: Feature[];
}): AgentPricingConfig {
return {
features: features.map(featureToAgentFeature),
products: products.map(productV2ToAgentProduct),
};
}

View File

@@ -192,7 +192,11 @@ export const getFeaturePriceItemDisplay = ({
: "";
// Build price string (e.g., "$0.01")
const priceStr = formatTiers({ item, currency, amountFormatOptions }) ?? "";
const priceStr =
formatTiers({ item, currency, amountFormatOptions }) ??
(notNullish(item.price)
? formatAmount({ currency, amount: item.price, amountFormatOptions })
: "");
// Build billing unit string (e.g., "credit" or "100 credits")
const billingUnits = item.billing_units ?? 1;

View File

@@ -0,0 +1,64 @@
import { CornerDownLeftIcon, Loader2Icon } from "lucide-react";
import type { FormEvent, KeyboardEvent } from "react";
import { InputGroupButton } from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
/**
* Compact inline prompt input with submit button on the same line.
* Designed for simple text input without attachments.
*/
export function CompactPromptInput({
value,
onChange,
onSubmit,
placeholder = "Describe changes...",
isLoading,
className,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder?: string;
isLoading?: boolean;
className?: string;
}) {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (value.trim() && !isLoading) onSubmit();
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey && value.trim() && !isLoading) {
e.preventDefault();
onSubmit();
}
};
return (
<form onSubmit={handleSubmit} className={cn("w-full", className)}>
<div className="flex items-center gap-2 rounded-xl border bg-white dark:bg-card px-3 py-2 dark:border-white/15 shadow-lg">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-t4"
/>
<InputGroupButton
type="submit"
variant="primary"
size="icon-sm"
disabled={!value.trim() || isLoading}
>
{isLoading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<CornerDownLeftIcon className="size-4" />
)}
</InputGroupButton>
</div>
</form>
);
}

View File

@@ -121,7 +121,7 @@ export function TableEmptyState({
<SmallSpinner />
</div>
) : (
<div className="text-t4 text-center w-full h-full items-center justify-center flex">
<div className="text-t4 text-xs text-center w-full h-full items-center justify-center flex">
{emptyStateChildren || emptyStateText}
</div>
)}

View File

@@ -1,7 +1,7 @@
export const EmptyState = ({ text }: { text: string | React.ReactNode }) => {
return (
<div className="flex justify-center items-center py-4 border-dashed border rounded-lg h-13 w-full bg-interactive-secondary dark:bg-transparent shadow-sm dark:shadow-none">
<span className="text-sm text-t4">{text}</span>
<span className="text-xs text-t4">{text}</span>
</div>
);
};

View File

@@ -1,276 +1,50 @@
import { useChat } from "@ai-sdk/react";
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai";
import { ImageIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useId, useRef, useState } from "react";
import {
Conversation,
ConversationContent,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import {
PromptInput,
PromptInputAttachment,
PromptInputAttachments,
PromptInputBody,
PromptInputButton,
PromptInputFooter,
PromptInputHeader,
type PromptInputMessage,
PromptInputSubmit,
PromptInputTextarea,
usePromptInputAttachments,
} from "@/components/ai-elements/prompt-input";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { Button } from "@/components/v2/buttons/Button";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { OrgDropdown } from "@/views/main-sidebar/components/OrgDropdown";
import { SidebarContext } from "@/views/main-sidebar/SidebarContext";
import { CopyPlansButton } from "./CopyPlansButton";
import {
AttachmentsHeader,
ImageUploadButton,
} from "./components/ChatInputComponents";
import { PricingChatPanel } from "./components/PricingChatPanel";
import { TemplatePrompts } from "./components/TemplatePrompts";
import { WelcomeHeader } from "./components/WelcomeHeader";
import { usePricingAgentChat } from "./hooks/usePricingAgentChat";
import { PricingConfigSheet } from "./PricingConfigSheet";
import { PricingPreview } from "./PricingPreview";
import type { AgentPricingConfig } from "./pricingAgentUtils";
interface AIChatViewProps {
onBack: () => void;
}
function ImageUploadButton({ disabled }: { disabled?: boolean }) {
const attachments = usePromptInputAttachments();
return (
<PromptInputButton
onClick={() => attachments.openFileDialog()}
disabled={disabled}
title="Add image"
>
<ImageIcon className="size-4" />
</PromptInputButton>
);
}
function AttachmentsHeader() {
const attachments = usePromptInputAttachments();
if (!attachments.files.length) {
return null;
}
return (
<PromptInputHeader>
<PromptInputAttachments className="p-0 pt-2">
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</PromptInputHeader>
);
}
// Type for the build_pricing tool part
type BuildPricingToolPart = {
type: "tool-build_pricing";
toolCallId: string;
toolName: "build_pricing";
state:
| "input-streaming"
| "input-available"
| "output-available"
| "output-error";
input?: AgentPricingConfig;
output?: unknown;
errorText?: string;
};
interface PreviewOrg {
apiKey: string;
orgId: string;
orgSlug: string;
}
export function AIChatView({ onBack }: AIChatViewProps) {
const [input, setInput] = useState("");
const [hasStartedChat, setHasStartedChat] = useState(false);
const [pricingConfig, setPricingConfig] = useState<AgentPricingConfig | null>(
null,
);
const [jsonSheetConfig, setJsonSheetConfig] =
useState<AgentPricingConfig | null>(null);
const [previewOrg, setPreviewOrg] = useState<PreviewOrg | null>(null);
const [isPreviewSyncing, setIsPreviewSyncing] = useState(false);
const previewSetupRef = useRef<Promise<PreviewOrg | null> | null>(null);
const axiosInstance = useAxiosInstance();
// Session ID for PostHog AI tracing - groups all messages in a conversation
const reactId = useId();
const chatSessionIdRef = useRef(`pricing-chat-${reactId}-${Date.now()}`);
/** Setup the preview org (called once, memoized) */
const setupPreviewOrg = useCallback(async (): Promise<PreviewOrg | null> => {
// If already setting up, return the existing promise
if (previewSetupRef.current) {
return previewSetupRef.current;
}
const setupPromise = (async () => {
try {
console.log("[Preview] Setting up preview org...");
const response = await fetch(
`${import.meta.env.VITE_BACKEND_URL}/pricing-agent/preview/setup`,
{
method: "POST",
credentials: "include",
headers: {
"x-client-type": "dashboard",
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
const error = await response.json();
console.error("[Preview] Setup failed:", error);
return null;
}
const data = await response.json();
const org: PreviewOrg = {
apiKey: data.api_key,
orgId: data.org_id,
orgSlug: data.org_slug,
};
console.log("[Preview] Setup complete:", {
orgId: org.orgId,
orgSlug: org.orgSlug,
});
setPreviewOrg(org);
return org;
} catch (error) {
console.error("[Preview] Setup error:", error);
return null;
}
})();
previewSetupRef.current = setupPromise;
return setupPromise;
}, []);
/** Sync pricing config to the preview org */
const syncPreviewPricing = useCallback(
async (config: AgentPricingConfig) => {
// Ensure preview org is set up
let org = previewOrg;
if (!org) {
org = await setupPreviewOrg();
if (!org) {
console.error("[Preview] Cannot sync - preview org not available");
return;
}
}
setIsPreviewSyncing(true);
try {
console.log("[Preview] Syncing pricing config...");
console.log("[Preview] Features:", config.features.length);
console.log("[Preview] Products:", config.products.length);
const response = await axiosInstance.post(
"/pricing-agent/preview/sync",
{
features: config.features,
products: config.products,
},
);
console.log("[Preview] Sync complete:", response.data);
} catch (error) {
console.error("[Preview] Sync error:", error);
} finally {
setIsPreviewSyncing(false);
}
},
[axiosInstance, previewOrg, setupPreviewOrg],
);
const { messages, sendMessage, status, addToolOutput } = useChat({
transport: new DefaultChatTransport({
api: `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/chat`,
credentials: "include",
headers: {
"x-client-type": "dashboard",
},
body: {
sessionId: chatSessionIdRef.current,
},
}),
// Auto-submit when all tool results are available (for multi-step if needed)
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
// Handle client-side tool execution
onToolCall: async ({ toolCall }) => {
// Check for dynamic tools first
if (toolCall.dynamic) {
return;
}
if (toolCall.toolName === "build_pricing") {
const config = toolCall.input as AgentPricingConfig;
// Update the pricing preview
setPricingConfig(config);
// Sync to preview org (fire and forget)
syncPreviewPricing(config);
// Return the tool result (no await to avoid deadlocks)
addToolOutput({
tool: "build_pricing",
toolCallId: toolCall.toolCallId,
output: {
success: true,
productsCount: config.products.length,
featuresCount: config.features.length,
},
});
}
},
});
const handleSubmit = (message: PromptInputMessage) => {
if (
(!message.text.trim() && message.files.length === 0) ||
status !== "ready"
)
return;
setHasStartedChat(true);
sendMessage({
text: message.text,
files: message.files,
});
setInput("");
};
const {
messages,
input,
setInput,
isLoading,
hasStartedChat,
pricingConfig,
previewOrg,
isPreviewSyncing,
jsonSheetConfig,
setJsonSheetConfig,
handleSubmit,
handleStartNewChat,
} = usePricingAgentChat();
const handleSelectTemplate = ({ prompt }: { prompt: string }) => {
setInput(prompt);
};
const isLoading = status === "streaming" || status === "submitted";
const handleStartNewChat = () => {
setHasStartedChat(false);
setInput("");
setPricingConfig(null);
};
return (
<SidebarContext.Provider value={{ expanded: true, setExpanded: () => {} }}>
<div className="w-full h-full flex flex-col bg-background">
@@ -389,124 +163,16 @@ export function AIChatView({ onBack }: AIChatViewProps) {
transition={{ duration: 0.4, delay: 0.3 }}
className="w-1/3 flex flex-col pt-14"
>
<Conversation className="flex-1">
<ConversationContent className="px-6">
{messages.map((message) => (
<Message key={message.id} from={message.role}>
<MessageContent>
{message.parts.map((part, partIndex) => {
switch (part.type) {
case "text":
return (
<MessageResponse key={partIndex}>
{part.text}
</MessageResponse>
);
case "file": {
const isImage =
part.mediaType?.startsWith("image/");
if (!isImage || !part.url) return null;
return (
<div
key={partIndex}
className="flex items-center gap-1.5 rounded-md border border-border px-1.5 py-1 w-fit"
>
<img
src={part.url}
alt={part.filename || "Attached image"}
className="w-90 rounded object-cover"
/>
</div>
);
}
case "tool-build_pricing": {
const toolPart = part as BuildPricingToolPart;
return (
<div
key={toolPart.toolCallId}
className="flex items-center gap-3 text-sm"
>
{toolPart.state === "input-streaming" ||
toolPart.state === "input-available" ? (
<Shimmer className="text-sm">
Building pricing configuration
</Shimmer>
) : toolPart.state === "output-error" ? (
<span className="text-t4">
Error generating pricing
</span>
) : (
<>
<span className="text-t4 font-normal">
Generated{" "}
{toolPart.input?.products.length ??
0}{" "}
product(s) and{" "}
{toolPart.input?.features.length ??
0}{" "}
feature(s)
</span>
<Button
variant="secondary"
size="sm"
onClick={() =>
setJsonSheetConfig(
toolPart.input ?? null,
)
}
className="text-t3 text-xs"
>
View JSON
</Button>
</>
)}
</div>
);
}
default:
return null;
}
})}
</MessageContent>
</Message>
))}
{isLoading &&
messages.length > 0 &&
messages[messages.length - 1]?.role === "user" && (
<div className="flex items-center gap-2 text-t3 text-sm">
<Shimmer>Planning next steps</Shimmer>
</div>
)}
</ConversationContent>
</Conversation>
<div className="px-6 pb-6 pt-2">
<PromptInput
onSubmit={handleSubmit}
accept="image/*"
multiple
>
<AttachmentsHeader />
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Describe your app's pricing"
disabled={isLoading}
/>
</PromptInputBody>
<PromptInputFooter className="justify-between">
<ImageUploadButton disabled={isLoading} />
<PromptInputSubmit
disabled={isLoading}
variant="primary"
/>
</PromptInputFooter>
</PromptInput>
</div>
<PricingChatPanel
messages={messages}
input={input}
onInputChange={setInput}
onSubmit={handleSubmit}
isLoading={isLoading}
onViewJson={setJsonSheetConfig}
placeholder="Describe your app's pricing"
className="flex-1"
/>
</motion.div>
{/* Right: Pricing Preview */}

View File

@@ -1,3 +1,4 @@
import type { AgentPricingConfig } from "@autumn/shared";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
@@ -15,7 +16,6 @@ import { useOrg } from "@/hooks/common/useOrg";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr, pushPage } from "@/utils/genUtils";
import type { AgentPricingConfig } from "./pricingAgentUtils";
interface CopyPlansButtonProps {
pricingConfig: AgentPricingConfig;

View File

@@ -105,7 +105,7 @@ function StepCard({
animate={{ flex: isActive ? 4 : 1 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className={cn(
"relative border dark:border-none rounded-xl bg-card cursor-pointer h-29 overflow-hidden",
"relative dark:border-none rounded-xl bg-card cursor-pointer h-29 overflow-hidden",
isActive
? ""
: "hover:border-primary/20 hover:bg-interactive-secondary-hover",
@@ -277,7 +277,7 @@ export function OnboardingGuide() {
}
return (
<div className="relative rounded-xl border bg-interactive-secondary p-4 shadow-sm">
<div className="relative rounded-xl bg-t8/10 p-4 border border-t8/20">
{/* Dismiss button */}
<button
type="button"
@@ -310,7 +310,7 @@ export function OnboardingGuide() {
to learn more about what you can do with Autumn
</>
) : (
"4 steps to get your app's billing done in less than 30 minutes"
"Setup billing and gating in your app in under 30 minutes"
)}
</p>
</div>

View File

@@ -1,3 +1,4 @@
import type { AgentPricingConfig } from "@autumn/shared";
import {
CodeGroup,
CodeGroupCode,
@@ -11,7 +12,6 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/v2/sheets/Sheet";
import type { AgentPricingConfig } from "./pricingAgentUtils";
interface PricingConfigSheetProps {
open: boolean;

View File

@@ -1,8 +1,12 @@
import type { AgentPricingConfig } from "@autumn/shared";
import type { ReactNode } from "react";
import { GroupedPlanCards } from "./preview/GroupedPlanCards";
import { PreviewCreditSchemaCard } from "./preview/PreviewCreditSchemaCard";
import { PreviewPlanCard } from "./preview/PreviewPlanCard";
import { transformToPreviewProducts } from "./preview/previewTypes";
import type { AgentPricingConfig } from "./pricingAgentUtils";
import {
getChangedFeatureIds,
getChangedProductIds,
transformToPreviewProducts,
} from "./preview/previewTypes";
interface PreviewOrg {
apiKey: string;
@@ -12,6 +16,7 @@ interface PreviewOrg {
interface PricingPreviewProps {
config: AgentPricingConfig | null;
initialConfig?: AgentPricingConfig | null;
previewOrg: PreviewOrg | null;
isSyncing: boolean;
headerActions?: ReactNode;
@@ -19,6 +24,7 @@ interface PricingPreviewProps {
export function PricingPreview({
config,
initialConfig,
previewOrg,
isSyncing,
headerActions,
@@ -32,6 +38,18 @@ export function PricingPreview({
})
: [];
// Compute which products have changed from initial config
const changedProductIds = getChangedProductIds({
initialConfig: initialConfig ?? null,
currentConfig: config,
});
// Compute which features have changed from initial config
const changedFeatureIds = getChangedFeatureIds({
initialConfig: initialConfig ?? null,
currentConfig: config,
});
// Find credit system features to display their schemas
const creditSystemFeatures = hasProducts
? config.features.filter(
@@ -65,7 +83,7 @@ export function PricingPreview({
{/* Content area with dotted grid background */}
<div
className="flex-1 flex flex-col p-4 overflow-auto gap-4 justify-center bg-card [--dot-color:rgba(0,0,0,0.15)] dark:[--dot-color:rgba(255,255,255,0.12)]"
className="flex-1 flex flex-col p-4 overflow-auto bg-card [--dot-color:rgba(0,0,0,0.15)] dark:[--dot-color:rgba(255,255,255,0.12)]"
style={{
backgroundImage:
"radial-gradient(circle, var(--dot-color) 1px, transparent 1px)",
@@ -73,17 +91,13 @@ export function PricingPreview({
}}
>
{hasProducts && (
<>
<div className="flex gap-3 flex-wrap justify-center">
{previewProducts.map((product) => (
<PreviewPlanCard
key={product.id}
product={product}
previewApiKey={previewOrg?.apiKey}
isSyncing={isSyncing}
/>
))}
</div>
<div className="flex flex-col gap-6 my-auto">
<GroupedPlanCards
products={previewProducts}
previewApiKey={previewOrg?.apiKey}
isSyncing={isSyncing}
changedProductIds={changedProductIds}
/>
{/* Credit system schema cards */}
{creditSystemFeatures.length > 0 && (
@@ -93,11 +107,12 @@ export function PricingPreview({
key={creditFeature.id}
creditFeature={creditFeature}
allFeatures={config.features}
isChanged={changedFeatureIds.has(creditFeature.id)}
/>
))}
</div>
)}
</>
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,71 @@
import type { AgentPricingConfig } from "@autumn/shared";
import { ImageIcon } from "lucide-react";
import {
PromptInputAttachment,
PromptInputAttachments,
PromptInputButton,
PromptInputHeader,
usePromptInputAttachments,
} from "@/components/ai-elements/prompt-input";
/**
* Type for the build_pricing tool part returned by the AI
*/
export type BuildPricingToolPart = {
type: "tool-build_pricing";
toolCallId: string;
toolName: "build_pricing";
state:
| "input-streaming"
| "input-available"
| "output-available"
| "output-error";
input?: AgentPricingConfig;
output?: unknown;
errorText?: string;
};
/**
* Preview org configuration for syncing pricing
*/
export interface PreviewOrg {
apiKey: string;
orgId: string;
orgSlug: string;
}
/**
* Button to open file dialog for image uploads in the chat input
*/
export function ImageUploadButton({ disabled }: { disabled?: boolean }) {
const attachments = usePromptInputAttachments();
return (
<PromptInputButton
onClick={() => attachments.openFileDialog()}
disabled={disabled}
title="Add image"
>
<ImageIcon className="size-4" />
</PromptInputButton>
);
}
/**
* Header showing attached files in the prompt input
*/
export function AttachmentsHeader() {
const attachments = usePromptInputAttachments();
if (!attachments.files.length) {
return null;
}
return (
<PromptInputHeader>
<PromptInputAttachments className="p-0 pt-2">
{(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments>
</PromptInputHeader>
);
}

View File

@@ -0,0 +1,169 @@
import type { AgentPricingConfig } from "@autumn/shared";
import type { UIMessage } from "ai";
import {
Conversation,
ConversationContent,
} from "@/components/ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import {
PromptInput,
PromptInputBody,
PromptInputFooter,
type PromptInputMessage,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { Button } from "@/components/v2/buttons/Button";
import { cn } from "@/lib/utils";
import {
AttachmentsHeader,
type BuildPricingToolPart,
ImageUploadButton,
} from "./ChatInputComponents";
interface PricingChatPanelProps {
messages: UIMessage[];
input: string;
onInputChange: (value: string) => void;
onSubmit: (message: PromptInputMessage) => void;
isLoading: boolean;
onViewJson?: (config: AgentPricingConfig) => void;
placeholder?: string;
className?: string;
inputClassName?: string;
}
/**
* Reusable chat panel for pricing agent conversations.
* Renders messages list and prompt input.
*/
export function PricingChatPanel({
messages,
input,
onInputChange,
onSubmit,
isLoading,
onViewJson,
placeholder = "Describe your app's pricing",
className,
inputClassName,
}: PricingChatPanelProps) {
return (
<div className={cn("flex flex-col min-h-0", className)}>
<Conversation className="flex-1">
<ConversationContent className="px-6">
{messages.map((message) => (
<Message key={message.id} from={message.role}>
<MessageContent>
{message.parts.map((part, partIndex) => {
switch (part.type) {
case "text":
return (
<MessageResponse key={partIndex}>
{part.text}
</MessageResponse>
);
case "file": {
const isImage = part.mediaType?.startsWith("image/");
if (!isImage || !part.url) return null;
return (
<div
key={partIndex}
className="flex items-center gap-1.5 rounded-md border border-border px-1.5 py-1 w-fit"
>
<img
src={part.url}
alt={part.filename || "Attached image"}
className="w-90 rounded object-cover"
/>
</div>
);
}
case "tool-build_pricing": {
const toolPart = part as BuildPricingToolPart;
return (
<div
key={toolPart.toolCallId}
className="flex items-center gap-3 text-sm"
>
{toolPart.state === "input-streaming" ||
toolPart.state === "input-available" ? (
<Shimmer className="text-sm">
Building pricing configuration
</Shimmer>
) : toolPart.state === "output-error" ? (
<span className="text-t4">
Error generating pricing
</span>
) : (
<>
<span className="text-t4 font-normal">
Generated {toolPart.input?.products.length ?? 0}{" "}
product(s) and{" "}
{toolPart.input?.features.length ?? 0}{" "}
feature(s)
</span>
{onViewJson && toolPart.input && (
<Button
variant="secondary"
size="sm"
onClick={() => {
if (toolPart.input) {
onViewJson(toolPart.input);
}
}}
className="text-t3 text-xs"
>
View JSON
</Button>
)}
</>
)}
</div>
);
}
default:
return null;
}
})}
</MessageContent>
</Message>
))}
{isLoading &&
messages.length > 0 &&
messages[messages.length - 1]?.role === "user" && (
<div className="flex items-center gap-2 text-t3 text-sm">
<Shimmer>Planning next steps</Shimmer>
</div>
)}
</ConversationContent>
</Conversation>
<div className={cn("px-6 pb-6 pt-2", inputClassName)}>
<PromptInput onSubmit={onSubmit} accept="image/*" multiple>
<AttachmentsHeader />
<PromptInputBody>
<PromptInputTextarea
value={input}
onChange={(e) => onInputChange(e.target.value)}
placeholder={placeholder}
disabled={isLoading}
/>
</PromptInputBody>
<PromptInputFooter className="justify-between">
<ImageUploadButton disabled={isLoading} />
<PromptInputSubmit disabled={isLoading} variant="primary" />
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { useChat } from "@ai-sdk/react";
import type { AgentPricingConfig } from "@autumn/shared";
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
} from "ai";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import type { PreviewOrg } from "../components/ChatInputComponents";
export interface UsePricingAgentChatOptions {
initialConfig?: AgentPricingConfig | null;
}
export function usePricingAgentChat(options?: UsePricingAgentChatOptions) {
const [input, setInput] = useState("");
const [hasStartedChat, setHasStartedChat] = useState(
options?.initialConfig != null,
);
const [pricingConfig, setPricingConfig] = useState<AgentPricingConfig | null>(
options?.initialConfig ?? null,
);
const [jsonSheetConfig, setJsonSheetConfig] =
useState<AgentPricingConfig | null>(null);
const [previewOrg, setPreviewOrg] = useState<PreviewOrg | null>(null);
const [isPreviewSyncing, setIsPreviewSyncing] = useState(false);
const previewSetupRef = useRef<Promise<PreviewOrg | null> | null>(null);
const syncInProgressRef = useRef(false);
const initialSyncDoneRef = useRef(false);
const axiosInstance = useAxiosInstance();
// Session ID for PostHog AI tracing - groups all messages in a conversation
const reactId = useId();
const chatSessionIdRef = useRef(`pricing-chat-${reactId}-${Date.now()}`);
/** Setup the preview org (called once, memoized) */
const setupPreviewOrg = useCallback(async (): Promise<PreviewOrg | null> => {
// If already setting up, return the existing promise
if (previewSetupRef.current) {
return previewSetupRef.current;
}
const setupPromise = (async () => {
try {
console.log("[Preview] Setting up preview org...");
const response = await fetch(
`${import.meta.env.VITE_BACKEND_URL}/pricing-agent/preview/setup`,
{
method: "POST",
credentials: "include",
headers: {
"x-client-type": "dashboard",
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
const error = await response.json();
console.error("[Preview] Setup failed:", error);
return null;
}
const data = await response.json();
const org: PreviewOrg = {
apiKey: data.api_key,
orgId: data.org_id,
orgSlug: data.org_slug,
};
console.log("[Preview] Setup complete:", {
orgId: org.orgId,
orgSlug: org.orgSlug,
});
setPreviewOrg(org);
return org;
} catch (error) {
console.error("[Preview] Setup error:", error);
return null;
}
})();
previewSetupRef.current = setupPromise;
return setupPromise;
}, []);
/** Sync pricing config to the preview org */
const syncPreviewPricing = useCallback(
async (config: AgentPricingConfig) => {
// Prevent concurrent syncs
if (syncInProgressRef.current) {
console.log("[Preview] Sync already in progress, skipping...");
return;
}
// Ensure preview org is set up
let org = previewOrg;
if (!org) {
org = await setupPreviewOrg();
if (!org) {
console.error("[Preview] Cannot sync - preview org not available");
return;
}
}
syncInProgressRef.current = true;
setIsPreviewSyncing(true);
try {
console.log("[Preview] Syncing pricing config...");
console.log("[Preview] Features:", config.features.length);
console.log("[Preview] Products:", config.products.length);
const response = await axiosInstance.post(
"/pricing-agent/preview/sync",
{
features: config.features,
products: config.products,
},
);
console.log("[Preview] Sync complete:", response.data);
} catch (error) {
console.error("[Preview] Sync error:", error);
} finally {
syncInProgressRef.current = false;
setIsPreviewSyncing(false);
}
},
[axiosInstance, previewOrg, setupPreviewOrg],
);
// Sync initial config on mount if provided
useEffect(() => {
if (options?.initialConfig && !initialSyncDoneRef.current) {
initialSyncDoneRef.current = true;
setupPreviewOrg().then((org) => {
if (org) {
syncPreviewPricing(options.initialConfig as AgentPricingConfig);
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { messages, sendMessage, status, addToolOutput, setMessages } = useChat({
transport: new DefaultChatTransport({
api: `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/chat`,
credentials: "include",
headers: {
"x-client-type": "dashboard",
},
body: {
sessionId: chatSessionIdRef.current,
initialConfig: options?.initialConfig ?? null,
},
}),
// Auto-submit when all tool results are available (for multi-step if needed)
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
// Handle client-side tool execution
onToolCall: async ({ toolCall }) => {
// Check for dynamic tools first
if (toolCall.dynamic) {
return;
}
if (toolCall.toolName === "build_pricing") {
const config = toolCall.input as AgentPricingConfig;
// Update the pricing preview
setPricingConfig(config);
// Sync to preview org (fire and forget)
syncPreviewPricing(config);
// Return the tool result (no await to avoid deadlocks)
addToolOutput({
tool: "build_pricing",
toolCallId: toolCall.toolCallId,
output: {
success: true,
productsCount: config.products.length,
featuresCount: config.features.length,
},
});
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
if (
(!message.text.trim() && message.files.length === 0) ||
status !== "ready"
)
return;
setHasStartedChat(true);
sendMessage({
text: message.text,
files: message.files,
});
setInput("");
},
[status, sendMessage],
);
const handleStartNewChat = useCallback(() => {
setHasStartedChat(false);
setInput("");
setPricingConfig(null);
setMessages([]);
}, [setMessages]);
const isLoading = status === "streaming" || status === "submitted";
return {
// Chat state
messages,
input,
setInput,
status,
isLoading,
hasStartedChat,
setHasStartedChat,
// Pricing config
pricingConfig,
setPricingConfig,
// Preview org
previewOrg,
isPreviewSyncing,
// JSON sheet
jsonSheetConfig,
setJsonSheetConfig,
// Actions
handleSubmit,
handleStartNewChat,
};
}

View File

@@ -0,0 +1,49 @@
import { SectionTag } from "@/components/v2/badges/SectionTag";
import { PreviewPlanCard } from "./PreviewPlanCard";
import { groupPreviewProducts, type PreviewProduct } from "./previewTypes";
interface GroupedPlanCardsProps {
products: PreviewProduct[];
previewApiKey?: string;
isSyncing: boolean;
changedProductIds: Set<string>;
}
export function GroupedPlanCards({
products,
previewApiKey,
isSyncing,
changedProductIds,
}: GroupedPlanCardsProps) {
const { subscriptions, addOnSubscriptions, oneTimePlans } =
groupPreviewProducts(products);
const renderSection = (title: string, items: PreviewProduct[]) => {
if (items.length === 0) return null;
return (
<div className="flex flex-col gap-2">
<SectionTag className="self-center">{title}</SectionTag>
<div className="flex gap-3 flex-wrap justify-center">
{items.map((product) => (
<PreviewPlanCard
key={product.id}
product={product}
previewApiKey={previewApiKey}
isSyncing={isSyncing}
isChanged={changedProductIds.has(product.id)}
/>
))}
</div>
</div>
);
};
return (
<>
{renderSection("Subscriptions", subscriptions)}
{renderSection("Add-on subscriptions", addOnSubscriptions)}
{renderSection("One-off purchases", oneTimePlans)}
</>
);
}

View File

@@ -6,7 +6,7 @@ type CheckoutState = "idle" | "waiting_for_sync" | "creating_checkout";
interface PreviewCheckoutButtonProps {
productId: string;
previewApiKey: string;
previewApiKey?: string;
isSyncing: boolean;
}
@@ -18,6 +18,7 @@ export function PreviewCheckoutButton({
const [checkoutState, setCheckoutState] = useState<CheckoutState>("idle");
const createCheckout = useCallback(async () => {
if (!previewApiKey) return;
setCheckoutState("creating_checkout");
try {
console.log(
@@ -70,6 +71,7 @@ export function PreviewCheckoutButton({
}, [checkoutState, isSyncing, createCheckout]);
const handleClick = () => {
if (!previewApiKey) return;
if (isSyncing) {
// Wait for sync to complete
setCheckoutState("waiting_for_sync");
@@ -80,6 +82,7 @@ export function PreviewCheckoutButton({
};
const isLoading = checkoutState !== "idle";
const isDisabled = isLoading || !previewApiKey;
const getButtonText = () => {
switch (checkoutState) {
@@ -98,7 +101,7 @@ export function PreviewCheckoutButton({
size="sm"
className="w-full mt-auto"
onClick={handleClick}
disabled={isLoading}
disabled={isDisabled}
>
{isLoading ? (
<>

View File

@@ -1,3 +1,4 @@
import type { AgentFeature } from "@autumn/shared";
import { Coins } from "lucide-react";
import {
Card,
@@ -5,11 +6,12 @@ import {
CardHeader,
CardTitle,
} from "@/components/v2/cards/Card";
import type { AgentFeature } from "../pricingAgentUtils";
import { cn } from "@/lib/utils";
interface PreviewCreditSchemaCardProps {
creditFeature: AgentFeature;
allFeatures: AgentFeature[];
isChanged?: boolean;
}
/**
@@ -18,6 +20,7 @@ interface PreviewCreditSchemaCardProps {
export function PreviewCreditSchemaCard({
creditFeature,
allFeatures,
isChanged = false,
}: PreviewCreditSchemaCardProps) {
const creditSchema = creditFeature.credit_schema;
@@ -30,7 +33,13 @@ export function PreviewCreditSchemaCard({
const creditSingular = creditFeature.display?.singular ?? "credit";
return (
<Card className="w-[280px] bg-background flex flex-col gap-0 rounded-xl">
<Card
className={cn(
"w-[270px] bg-background flex flex-col gap-0 rounded-xl",
isChanged &&
"ring-2 ring-yellow-400/70 ring-offset-2 ring-offset-background",
)}
>
<CardHeader className="">
<div className="flex items-center gap-2">
<div className=" rounded-md">

View File

@@ -1,10 +1,6 @@
import {
BatteryHighIcon,
BoxArrowDownIcon,
CoinsIcon,
MoneyWavyIcon,
TicketIcon,
ToggleRightIcon,
WalletIcon,
} from "@phosphor-icons/react";
import type React from "react";
@@ -13,7 +9,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import type { AgentFeature } from "../pricingAgentUtils";
import { getFeatureIconConfig } from "@/views/products/features/utils/getFeatureIcon";
import type { PreviewProductItem } from "./previewTypes";
interface PreviewFeatureIconProps {
@@ -22,53 +18,8 @@ interface PreviewFeatureIconProps {
size?: number;
}
type FeatureTypeKey = AgentFeature["type"];
type BillingType = "included" | "prepaid" | "paid";
/**
* Get icon for feature type (left position)
*/
function getFeatureTypeIcon({
featureType,
size,
}: {
featureType: FeatureTypeKey;
size: number;
}): { icon: React.ReactNode; color: string; label: string } {
const weight = "duotone";
switch (featureType) {
case "boolean":
case "static":
return {
icon: <ToggleRightIcon size={size} weight={weight} />,
color: "text-red-500",
label: "Boolean",
};
case "credit_system":
return {
icon: <CoinsIcon size={size} weight={weight} />,
color: "text-pink-500",
label: "Credit System",
};
case "continuous_use":
return {
icon: <TicketIcon size={size} weight={weight} />,
color: "text-blue-500",
label: "Non-consumable",
};
default:
return {
icon: <BatteryHighIcon size={size} weight={weight} />,
color: "text-fuchsia-500",
label: "Consumable",
};
}
}
/**
* Determine billing type from item properties
*/
@@ -130,7 +81,7 @@ export function PreviewFeatureIcon({
}: PreviewFeatureIconProps) {
const iconData =
position === "left"
? getFeatureTypeIcon({ featureType: item.featureType, size })
? getFeatureIconConfig(item.featureType, null, size)
: getBillingTypeIcon({ billingType: getBillingType(item), size });
return (

View File

@@ -11,15 +11,13 @@ function DotIcon() {
}
export function PreviewFeatureRow({ item }: PreviewFeatureRowProps) {
// Use column layout only if item has both pricing AND included usage
const hasPricing = item.price != null && item.price > 0;
const hasIncludedUsage =
item.includedUsage != null &&
(item.includedUsage === "inf" || item.includedUsage > 0);
const useColumnLayout = hasPricing && hasIncludedUsage;
// Use column layout when secondary text starts with "then" (indicates pricing with included usage)
// This handles both flat price and tiered pricing scenarios
const useColumnLayout =
item.display.secondaryText?.startsWith("then") ?? false;
return (
<div className="flex items-center w-full px-2">
<div className="flex items-center w-full">
<div
className={`flex gap-1 min-w-0 overflow-hidden ${useColumnLayout ? "flex-col" : "flex-row items-center"}`}
>
@@ -35,7 +33,7 @@ export function PreviewFeatureRow({ item }: PreviewFeatureRowProps) {
</span>
</div>
{item.display.secondaryText && (
<span className="text-xs text-t3 truncate shrink-0">
<span className="text-xs text-t3 truncate min-w-0">
{item.display.secondaryText}
</span>
)}

View File

@@ -4,6 +4,7 @@ import {
CardFooter,
CardHeader,
} from "@/components/v2/cards/Card";
import { cn } from "@/lib/utils";
import { PreviewCheckoutButton } from "./PreviewCheckoutButton";
import { PreviewFeatureRow } from "./PreviewFeatureRow";
import { PreviewPlanHeader } from "./PreviewPlanHeader";
@@ -13,15 +14,23 @@ interface PreviewPlanCardProps {
product: PreviewProduct;
previewApiKey?: string;
isSyncing: boolean;
isChanged?: boolean;
}
export function PreviewPlanCard({
product,
previewApiKey,
isSyncing,
isChanged = false,
}: PreviewPlanCardProps) {
return (
<Card className="w-[280px] bg-background flex flex-col gap-0 rounded-xl">
<Card
className={cn(
"w-[270px] bg-interactive-secondary dark:bg-background flex flex-col gap-0 rounded-xl",
isChanged &&
"ring-2 ring-yellow-400/70 ring-offset-2 ring-offset-background",
)}
>
<CardHeader className="">
<PreviewPlanHeader product={product} />
</CardHeader>
@@ -39,7 +48,7 @@ export function PreviewPlanCard({
)}
</CardContent>
{previewApiKey && product.basePrice.type !== "free" && (
{product.basePrice.type !== "free" && (
<CardFooter className="pt-3">
<PreviewCheckoutButton
productId={product.id}

View File

@@ -12,7 +12,7 @@ export function PreviewPlanHeader({ product }: PreviewPlanHeaderProps) {
<div className="flex flex-col gap-1.5">
{/* Name row with badges */}
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground truncate">
<span className="text-sm font-medium text-foreground truncate max-w-[160px]">
{product.name}
</span>
<PlanTypeBadges

View File

@@ -1,22 +1,20 @@
import {
AppEnv,
type AgentFeature,
type AgentPricingConfig,
type AgentProduct,
type AgentProductItem,
agentFeatureToFeature,
agentItemToProductItem,
agentProductToProductV2,
type Feature,
FeatureType,
getProductItemDisplay,
Infinite,
type ProductItem,
type ProductV2,
isOneOffProductV2,
productV2ToFrontendProduct,
} from "@autumn/shared";
import {
type BasePriceDisplayResult,
getBasePriceDisplay,
} from "@/utils/product/basePriceDisplayUtils";
import type {
AgentFeature,
AgentProduct,
AgentProductItem,
} from "../pricingAgentUtils";
/**
* Preview-friendly product format derived from AgentPricingConfig
@@ -27,6 +25,7 @@ export interface PreviewProduct {
description?: string;
isAddOn?: boolean;
isDefault?: boolean;
isOneOff: boolean;
basePrice: BasePriceDisplayResult;
items: PreviewProductItem[];
freeTrial?: {
@@ -69,6 +68,15 @@ export function transformToPreviewProducts({
const frontendProduct = productV2ToFrontendProduct({ product: productV2 });
const basePrice = getBasePriceDisplay({ product: frontendProduct });
// Normalize items for isOneOffProductV2 check (undefined → null for interval)
// This is needed because agentItemToProductItem may leave interval as undefined,
// but isOneOffProductV2 checks for interval === null
const normalizedItems = productV2.items.map((item) => ({
...item,
interval: item.interval ?? null,
}));
const isOneOff = isOneOffProductV2({ items: normalizedItems });
// Transform feature items
const featureItems = (product.items ?? [])
.filter((item) => item.feature_id)
@@ -81,6 +89,7 @@ export function transformToPreviewProducts({
name: product.name,
isAddOn: product.is_add_on,
isDefault: product.is_default,
isOneOff,
basePrice,
items: featureItems,
freeTrial: product.free_trial
@@ -93,88 +102,6 @@ export function transformToPreviewProducts({
});
}
/**
* Convert AgentProduct to ProductV2 for use with shared utilities
*/
function agentProductToProductV2(product: AgentProduct): ProductV2 {
const items: ProductItem[] = (product.items ?? []).map(
agentItemToProductItem,
);
return {
internal_id: product.id,
id: product.id,
name: product.name,
description: null,
is_add_on: product.is_add_on ?? false,
is_default: product.is_default ?? false,
version: 1,
group: product.group ?? null,
env: AppEnv.Sandbox,
free_trial: null, // Free trial display handled separately in PreviewProduct
items,
created_at: Date.now(),
};
}
/**
* Map AgentFeature type string to FeatureType enum
*/
function mapFeatureType(agentType: AgentFeature["type"]): FeatureType {
switch (agentType) {
case "boolean":
case "static":
return FeatureType.Boolean;
case "credit_system":
return FeatureType.CreditSystem;
default:
return FeatureType.Metered;
}
}
/**
* Convert AgentFeature to shared Feature type
*/
function agentFeatureToFeature(agentFeature: AgentFeature): Feature {
return {
internal_id: agentFeature.id,
org_id: "",
created_at: Date.now(),
env: AppEnv.Sandbox,
id: agentFeature.id,
name: agentFeature.name ?? agentFeature.display?.plural ?? agentFeature.id,
type: mapFeatureType(agentFeature.type),
config: null,
display: agentFeature.display
? {
singular: agentFeature.display.singular,
plural: agentFeature.display.plural,
}
: undefined,
archived: false,
event_names: [],
};
}
/**
* Convert AgentProductItem to shared ProductItem type
*/
function agentItemToProductItem(item: AgentProductItem): ProductItem {
return {
feature_id: item.feature_id,
included_usage:
item.included_usage === "inf"
? Infinite
: (item.included_usage ?? undefined),
interval: item.interval as ProductItem["interval"],
price: item.price,
billing_units: item.billing_units,
usage_model: item.usage_model as ProductItem["usage_model"],
tiers:
item.price != null ? [{ to: Infinite, amount: item.price }] : undefined,
};
}
function transformToPreviewItem({
item,
features,
@@ -217,3 +144,248 @@ function transformToPreviewItem({
},
};
}
// ============ CHANGE DETECTION ============
/** Normalize falsy values for comparison (undefined, null, false all become false) */
function normalizeBool(val: boolean | undefined | null): boolean {
return val === true;
}
/** Normalize optional strings (undefined, null, and empty string all become null) */
function normalizeStr(val: string | undefined | null): string | null {
return val || null;
}
/** Normalize optional numbers */
function normalizeNum(
val: number | "inf" | undefined | null,
): number | "inf" | null {
return val ?? null;
}
function areItemsEqual(
items1: AgentProductItem[],
items2: AgentProductItem[],
): boolean {
if (items1.length !== items2.length) return false;
return items1.every((item1, index) => {
const item2 = items2[index];
return (
normalizeStr(item1.feature_id) === normalizeStr(item2.feature_id) &&
normalizeNum(item1.included_usage) ===
normalizeNum(item2.included_usage) &&
normalizeStr(item1.interval) === normalizeStr(item2.interval) &&
normalizeNum(item1.price) === normalizeNum(item2.price) &&
normalizeStr(item1.usage_model) === normalizeStr(item2.usage_model) &&
normalizeNum(item1.billing_units) === normalizeNum(item2.billing_units) &&
JSON.stringify(item1.tiers ?? null) ===
JSON.stringify(item2.tiers ?? null)
);
});
}
function areProductsEqual(
product1: AgentProduct,
product2: AgentProduct,
): boolean {
if (
product1.name !== product2.name ||
normalizeBool(product1.is_add_on) !== normalizeBool(product2.is_add_on) ||
normalizeBool(product1.is_default) !== normalizeBool(product2.is_default) ||
normalizeStr(product1.group) !== normalizeStr(product2.group)
) {
return false;
}
// Compare free trial (both falsy = equal)
const ft1 = product1.free_trial;
const ft2 = product2.free_trial;
const hasFt1 = ft1 != null;
const hasFt2 = ft2 != null;
if (hasFt1 !== hasFt2) return false;
if (ft1 && ft2) {
if (ft1.length !== ft2.length || ft1.duration !== ft2.duration) {
return false;
}
}
// Compare items
const items1 = product1.items ?? [];
const items2 = product2.items ?? [];
return areItemsEqual(items1, items2);
}
/** Returns a Set of product IDs that have changed from the initial config */
export function getChangedProductIds({
initialConfig,
currentConfig,
}: {
initialConfig: AgentPricingConfig | null;
currentConfig: AgentPricingConfig | null;
}): Set<string> {
const changedIds = new Set<string>();
if (!initialConfig || !currentConfig) {
return changedIds;
}
const initialProductMap = new Map(
initialConfig.products.map((p) => [p.id, p]),
);
for (const currentProduct of currentConfig.products) {
const initialProduct = initialProductMap.get(currentProduct.id);
// New product (wasn't in initial config)
if (!initialProduct) {
changedIds.add(currentProduct.id);
continue;
}
// Existing product - check if changed
if (!areProductsEqual(initialProduct, currentProduct)) {
changedIds.add(currentProduct.id);
}
}
return changedIds;
}
function areFreeTrialsEqual(
ft1: AgentProduct["free_trial"],
ft2: AgentProduct["free_trial"],
): boolean {
if (!ft1 && !ft2) return true;
if (!ft1 || !ft2) return false;
return ft1.length === ft2.length && ft1.duration === ft2.duration;
}
/** Returns product IDs that will create new versions (existing + customers + billing changes) */
export function getVersionedProductIds({
initialConfig,
currentConfig,
productCounts,
}: {
initialConfig: AgentPricingConfig | null;
currentConfig: AgentPricingConfig | null;
productCounts: Record<string, { all: number }>;
}): string[] {
if (!initialConfig || !currentConfig) return [];
const initialProductMap = new Map(
initialConfig.products.map((p) => [p.id, p]),
);
return currentConfig.products
.filter((currentProduct) => {
const initialProduct = initialProductMap.get(currentProduct.id);
if (!initialProduct) return false;
const customerCount = productCounts[currentProduct.id]?.all ?? 0;
if (customerCount === 0) return false;
const itemsChanged = !areItemsEqual(
initialProduct.items ?? [],
currentProduct.items ?? [],
);
const freeTrialChanged = !areFreeTrialsEqual(
initialProduct.free_trial,
currentProduct.free_trial,
);
return itemsChanged || freeTrialChanged;
})
.map((product) => product.id);
}
/** Check if two credit system features are equal */
function areFeaturesEqual(
feature1: AgentFeature,
feature2: AgentFeature,
): boolean {
if (feature1.name !== feature2.name || feature1.type !== feature2.type) {
return false;
}
// Compare display - only if BOTH features have display defined
// The display field is cosmetic (UI labels) and doesn't affect billing.
// If one has display and the other doesn't, it's not a meaningful change.
const d1 = feature1.display;
const d2 = feature2.display;
if (d1 && d2) {
if (d1.singular !== d2.singular || d1.plural !== d2.plural) {
return false;
}
}
// Compare credit schema
const cs1 = feature1.credit_schema ?? [];
const cs2 = feature2.credit_schema ?? [];
if (cs1.length !== cs2.length) return false;
return cs1.every((s1, index) => {
const s2 = cs2[index];
return (
s1.metered_feature_id === s2.metered_feature_id &&
s1.credit_cost === s2.credit_cost
);
});
}
/** Returns a Set of feature IDs that have changed from the initial config */
export function getChangedFeatureIds({
initialConfig,
currentConfig,
}: {
initialConfig: AgentPricingConfig | null;
currentConfig: AgentPricingConfig | null;
}): Set<string> {
const changedIds = new Set<string>();
if (!initialConfig || !currentConfig) {
return changedIds;
}
const initialFeatureMap = new Map(
initialConfig.features.map((f) => [f.id, f]),
);
for (const currentFeature of currentConfig.features) {
const initialFeature = initialFeatureMap.get(currentFeature.id);
// New feature (wasn't in initial config)
if (!initialFeature) {
changedIds.add(currentFeature.id);
continue;
}
// Existing feature - check if changed
if (!areFeaturesEqual(initialFeature, currentFeature)) {
changedIds.add(currentFeature.id);
}
}
return changedIds;
}
// ============ PRODUCT GROUPING ============
export interface GroupedPreviewProducts {
subscriptions: PreviewProduct[];
addOnSubscriptions: PreviewProduct[];
oneTimePlans: PreviewProduct[];
}
/** Groups products into subscriptions, add-on subscriptions, and one-time plans (mirrors ProductListTable logic) */
export function groupPreviewProducts(
products: PreviewProduct[],
): GroupedPreviewProducts {
const oneTimePlans = products.filter((p) => p.isOneOff);
const recurringPlans = products.filter((p) => !p.isOneOff);
const subscriptions = recurringPlans.filter((p) => !p.isAddOn);
const addOnSubscriptions = recurringPlans.filter((p) => p.isAddOn);
return { subscriptions, addOnSubscriptions, oneTimePlans };
}

View File

@@ -1,137 +0,0 @@
import type { PricingTier } from "./templateConfigs";
/**
* Types for the pricing config returned by the AI agent's build_pricing tool
*/
export interface AgentFeature {
id: string;
name?: string | null;
type:
| "static"
| "boolean"
| "single_use"
| "continuous_use"
| "credit_system";
display?: {
singular: string;
plural: string;
} | null;
credit_schema?: Array<{
metered_feature_id: string;
credit_cost: number;
}> | null;
}
export interface AgentProductItem {
feature_id?: string | null;
included_usage?: number | "inf" | null;
interval?: string | null;
price?: number | null;
usage_model?: "prepaid" | "pay_per_use" | null;
billing_units?: number | null;
}
export interface AgentFreeTrial {
length: number;
duration: "day" | "month" | "year";
unique_fingerprint?: boolean;
card_required?: boolean;
}
export interface AgentProduct {
id: string;
name: string;
is_add_on?: boolean;
is_default?: boolean;
group?: string;
items?: AgentProductItem[];
free_trial?: AgentFreeTrial | null;
}
export interface AgentPricingConfig {
features: AgentFeature[];
products: AgentProduct[];
}
/**
* Transform an AgentPricingConfig (from the AI) into PricingTier[] (for the UI)
*/
function transformConfigToTiers({
config,
features,
}: {
config: AgentPricingConfig;
features: AgentFeature[];
}): PricingTier[] {
return config.products.map((product) => {
// Find the base price (item without feature_id, or first priced item)
const basePrice = product.items?.find(
(item) => !item.feature_id && item.price != null,
);
const fixedPrice = basePrice?.price ?? 0;
const interval = basePrice?.interval ?? "month";
// Determine price display
let priceDisplay: string;
if (
fixedPrice === 0 &&
!product.items?.some((i) => i.price && i.price > 0)
) {
priceDisplay = "Free";
} else if (fixedPrice > 0) {
priceDisplay = `$${fixedPrice}`;
} else {
// Usage-based only
priceDisplay = "Usage-based";
}
// Build feature list for the card
const featureList: string[] = [];
for (const item of product.items ?? []) {
if (item.feature_id) {
const feature = features.find((f) => f.id === item.feature_id);
const featureName =
feature?.name ?? feature?.display?.plural ?? item.feature_id;
if (item.included_usage === "inf") {
featureList.push(`Unlimited ${featureName}`);
} else if (item.included_usage != null && item.included_usage > 0) {
featureList.push(
`${item.included_usage.toLocaleString()} ${featureName}`,
);
} else if (item.price != null && item.price > 0) {
featureList.push(
`${featureName} at $${item.price}${item.billing_units ? `/${item.billing_units}` : "/unit"}`,
);
}
} else if (item.price != null && item.price > 0 && !basePrice) {
// It's a standalone price item
featureList.push(`$${item.price}/${item.interval ?? "month"} base`);
}
}
// Add free trial info if present
if (product.free_trial) {
featureList.push(
`${product.free_trial.length} ${product.free_trial.duration} free trial`,
);
}
// Determine if this tier should be highlighted
// Typically the "Pro" or middle tier, or explicitly named
const isHighlighted =
product.name.toLowerCase().includes("pro") ||
product.name.toLowerCase().includes("plus") ||
product.name.toLowerCase().includes("premium");
return {
name: product.name,
price: priceDisplay,
interval: fixedPrice > 0 ? interval : undefined,
description: product.is_add_on ? "Add-on" : undefined,
features: featureList.length > 0 ? featureList : ["Basic features"],
highlighted: isHighlighted,
};
});
}

View File

@@ -0,0 +1,5 @@
/**
* Re-export convertToAgentConfig from shared for backwards compatibility.
* The actual implementation now lives in @autumn/shared.
*/
export { convertToAgentConfig } from "@autumn/shared";

View File

@@ -7,11 +7,7 @@ import {
ProductItemInterval,
productV2ToBasePrice,
} from "@autumn/shared";
import {
ArrowsClockwiseIcon,
BarcodeIcon,
CheckCircleIcon,
} from "@phosphor-icons/react";
import { ArrowsClockwiseIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { GroupedTabButton } from "@/components/v2/buttons/GroupedTabButton";
import { FormLabel } from "@/components/v2/form/FormLabel";
import {
@@ -23,6 +19,8 @@ import {
InputGroupAddon,
InputGroupInput,
} from "@/components/v2/inputs/InputGroup";
import { AreaRadioGroupItem } from "@/components/v2/radio-groups/AreaRadioGroupItem";
import { RadioGroup } from "@/components/v2/radio-groups/RadioGroup";
import { SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useOrg } from "@/hooks/common/useOrg";
import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox";
@@ -106,95 +104,148 @@ export const BasePriceSection = ({
const disabled = nullish(basePrice);
// Determine if we're in "usage" (per unit only) mode or "base price" mode
const isPerUnitOnly = basePriceType === "usage";
// Get the billing type (recurring or one-off) - default to recurring
const billingType =
basePriceType === "usage"
? "recurring"
: basePriceType === "one-off"
? "one-off"
: "recurring";
const handleBillingTypeChange = (value: string) => {
// Only change billing type if we're in base price mode
if (isPerUnitOnly) return;
// Check if there's already a price item
const hasPriceItem = product.items.some((item) => isPriceItem(item));
if (!hasPriceItem) {
// Recreate the price item with default price of 0
const newPriceItem: ProductItem = {
price: "" as unknown as number,
interval: value === "one-off" ? null : ProductItemInterval.Month,
interval_count: 1,
};
setProduct({
...product,
basePriceType: value as "recurring" | "one-off",
items: [...product.items, newPriceItem],
});
return;
}
// Update existing price item
setProduct({
...product,
basePriceType: value as "recurring" | "one-off",
items: product.items.map((item) => {
if (isPriceItem(item)) {
return {
...item,
interval: value === "one-off" ? null : ProductItemInterval.Month,
};
}
return item;
}),
});
};
const handlePriceTypeChange = (value: string) => {
if (value === "usage") {
// Switch to per unit only - remove base price item
setProduct({
...product,
basePriceType: "usage",
items: product.items.filter((item) => !isPriceItem(item)),
});
} else {
// Switch to base price - restore price item with current billing type
const hasPriceItem = product.items.some((item) => isPriceItem(item));
if (!hasPriceItem) {
const newPriceItem: ProductItem = {
price: "" as unknown as number,
interval:
billingType === "one-off" ? null : ProductItemInterval.Month,
interval_count: 1,
};
setProduct({
...product,
basePriceType: billingType as "recurring" | "one-off",
items: [...product.items, newPriceItem],
});
} else {
setProduct({
...product,
basePriceType: billingType as "recurring" | "one-off",
});
}
}
};
return (
<SheetSection
title="Base Price"
withSeparator={withSeparator}
description={`Flat-rate cost to access this plan.${isCreatingNewPlan ? " You can add per-unit prices later." : ""}`}
>
<div className="space-y-3">
<div className="space-y-2">
<GroupedTabButton
value={basePriceType ?? "recurring"}
className="w-full"
onValueChange={(value) => {
//if usage based, remove the base price item
if (value === "usage") {
setProduct({
...product,
basePriceType: "usage",
items: product.items.filter((item) => !isPriceItem(item)),
});
return;
}
// Check if there's already a price item
const hasPriceItem = product.items.some((item) =>
isPriceItem(item),
);
if (!hasPriceItem) {
// Recreate the price item with default price of 0
const newPriceItem: ProductItem = {
price: "" as unknown as number,
interval:
value === "one-off" ? null : ProductItemInterval.Month,
interval_count: 1,
};
setProduct({
...product,
basePriceType: value as "recurring" | "one-off" | "usage",
items: [...product.items, newPriceItem],
});
return;
}
// Update existing price item
setProduct({
...product,
basePriceType: value as "recurring" | "one-off" | "usage",
items: product.items.map((item) => {
if (isPriceItem(item)) {
return {
...item,
interval:
value === "one-off" ? null : ProductItemInterval.Month,
};
}
return item;
}),
});
}}
options={[
{
value: "recurring",
label: "Recurring",
icon: (
<ArrowsClockwiseIcon
className="size-[14px]"
weight="regular"
/>
),
},
{
value: "one-off",
label: "One-off",
icon: (
<CheckCircleIcon className="size-[14px]" weight="regular" />
),
},
{
value: "usage",
label: "Per unit only",
icon: <BarcodeIcon className="size-[14px]" weight="regular" />,
},
]}
/>
{/* <p className="text-t4 text-sm">{priceDescription}</p> */}
<SheetSection title="Plan Price" withSeparator={withSeparator}>
<div className="space-y-4">
<div className="py-2">
<RadioGroup
value={isPerUnitOnly ? "usage" : "base"}
onValueChange={handlePriceTypeChange}
className="space-y-1"
>
<AreaRadioGroupItem
value="base"
label="Base price"
description={`This plan has a fixed price. ${isCreatingNewPlan ? "You can add per-unit prices later." : ""}`}
/>
<AreaRadioGroupItem
value="usage"
label="Per unit only"
description="Plan price is based entirely on usage or units purchased."
/>
</RadioGroup>
</div>
<div className="h-13">
{basePriceType !== "usage" ? (
{isPerUnitOnly ? (
<InfoBox variant="note">
This plan has no base price. You can add per unit prices, such as
per "seat" or "credit", when adding features.
</InfoBox>
) : (
<>
<div className="space-y-2">
<GroupedTabButton
value={billingType}
className="w-full"
onValueChange={handleBillingTypeChange}
options={[
{
value: "recurring",
label: "Recurring",
icon: (
<ArrowsClockwiseIcon
className="size-[14px]"
weight="regular"
/>
),
},
{
value: "one-off",
label: "One-off",
icon: (
<CheckCircleIcon
className="size-[14px]"
weight="regular"
/>
),
},
]}
/>
</div>
<div className="flex gap-2">
<div className="w-full">
<FormLabel disabled={disabled}>Price</FormLabel>
@@ -225,24 +276,19 @@ export const BasePriceSection = ({
</InputGroupAddon>
</InputGroup>
</div>
{basePriceType === "recurring" && (
{billingType === "recurring" && (
<div className="w-full">
<SelectBillingCycle
item={basePrice}
setItem={setItem}
disabled={disabled}
filterOneOff={basePriceType === "recurring"}
filterOneOff={billingType === "recurring"}
/>
</div>
)}
</div>
) : (
<InfoBox variant="note">
This plan has no base price. You can add per unit prices, such as
per "seat" or "credit", when adding features.
</InfoBox>
)}
</div>
</>
)}
</div>
</SheetSection>
);

View File

@@ -1,6 +1,6 @@
import {
type FrontendProductItem,
type ProductV2,
type UpdateProductV2Params,
UpdateProductV2ParamsSchema,
} from "@autumn/shared";
import type { AxiosError, AxiosInstance } from "axios";
@@ -18,7 +18,7 @@ export const updateProduct = async ({
}: {
axiosInstance: AxiosInstance;
productId: string;
product: ProductV2;
product: UpdateProductV2Params;
onSuccess: () => Promise<void>;
}) => {
const validated = validateItemsBeforeSave(

View File

@@ -1,13 +1,32 @@
import { useState } from "react";
import { useClearQueryParams } from "@/hooks/common/useClearQueryParams";
import { ProductsAIChatView } from "./components/ProductsAIChatView";
import { ProductsPageHeader } from "./components/ProductsPageHeader";
import {
type ProductsViewMode,
ProductsViewToggle,
} from "./components/ProductsViewToggle";
import { ProductListCreateButton } from "./components/product-list/ProductListCreateButton";
import { ProductListMenuButton } from "./components/product-list/ProductListMenuButton";
import { ProductListTable } from "./components/product-list/ProductListTable";
export const ProductsPage = () => {
// Clean up onboarding-related query params after a delay
useClearQueryParams({ queryParams: ["step", "product_id"] });
const [viewMode, setViewMode] = useState<ProductsViewMode>("list");
return (
<div className="h-fit max-h-full px-10">
<ProductListTable />
{/* Shared header - always visible */}
<ProductsPageHeader>
<ProductsViewToggle value={viewMode} onValueChange={setViewMode} />
<ProductListCreateButton />
<ProductListMenuButton />
</ProductsPageHeader>
{/* Conditional content */}
{viewMode === "list" ? <ProductListTable /> : <ProductsAIChatView />}
</div>
);
};

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import { Button } from "@/components/v2/buttons/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/v2/dialogs/Dialog";
import { Input } from "@/components/v2/inputs/Input";
const CONFIRM_TEXT = "confirm";
interface ConfirmBatchVersionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
productIds: string[];
onConfirm: () => Promise<void>;
}
export function ConfirmBatchVersionDialog({
open,
onOpenChange,
productIds,
onConfirm,
}: ConfirmBatchVersionDialogProps) {
const [confirmText, setConfirmText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const isValid = confirmText.trim() === CONFIRM_TEXT;
const handleConfirm = async () => {
if (!isValid) return;
setIsLoading(true);
try {
await onConfirm();
onOpenChange(false);
} finally {
setIsLoading(false);
setConfirmText("");
}
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setConfirmText("");
}
onOpenChange(nextOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create new versions?</DialogTitle>
<DialogDescription className="text-sm flex flex-col gap-4">
<p>
The following plans have active customers and will create{" "}
<span className="font-bold">new versions</span>:
</p>
<ul className="list-disc pl-5 space-y-1">
{productIds.map((productId) => (
<li key={productId}>
<code className="font-bold">{productId}</code>
</li>
))}
</ul>
<p>
Type <code className="font-bold">{CONFIRM_TEXT}</code> to continue.
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
type="text"
placeholder={CONFIRM_TEXT}
className="w-full"
/>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleConfirm}
isLoading={isLoading}
disabled={!isValid}
>
Create new versions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,150 @@
import { useMemo } from "react";
import { CompactPromptInput } from "@/components/ai-elements/CompactPromptInput";
import { Button } from "@/components/v2/buttons/Button";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { PricingChatPanel } from "@/views/onboarding4/components/PricingChatPanel";
import { usePricingAgentChat } from "@/views/onboarding4/hooks/usePricingAgentChat";
import { PricingConfigSheet } from "@/views/onboarding4/PricingConfigSheet";
import { PricingPreview } from "@/views/onboarding4/PricingPreview";
import { convertToAgentConfig } from "@/views/onboarding4/utils/convertToAgentConfig";
import { usePricingConfigSave } from "../hooks/usePricingConfigSave";
import { ConfirmBatchVersionDialog } from "./ConfirmBatchVersionDialog";
/**
* AI chat view for the products page.
* Similar to the onboarding AIChatView but starts with existing plans loaded.
*/
export function ProductsAIChatView() {
const { products } = useProductsQuery();
const { features } = useFeaturesQuery();
// Filter out archived products
const activeProducts = useMemo(
() => products.filter((p) => !p.archived),
[products],
);
// Convert existing products/features to initial config
const initialConfig = useMemo(() => {
if (activeProducts.length === 0 && features.length === 0) {
return null;
}
return convertToAgentConfig({ products: activeProducts, features });
}, [activeProducts, features]);
const {
messages,
input,
setInput,
isLoading,
pricingConfig,
previewOrg,
isPreviewSyncing,
jsonSheetConfig,
setJsonSheetConfig,
handleSubmit,
handleStartNewChat,
} = usePricingAgentChat({ initialConfig });
const {
isSaving,
confirmOpen,
setConfirmOpen,
versionedProductIds,
handleSave,
handleConfirmSave,
} = usePricingConfigSave({ initialConfig });
// Track if chat has started (has any messages)
const chatStarted = messages.length > 0;
// Use the AI-generated config if available, otherwise fall back to initial
const displayConfig = pricingConfig ?? initialConfig;
return (
<div className="w-full h-[calc(100vh-200px)] flex flex-col">
<div className="flex-1 flex min-h-0 relative">
{/* Left: Chat - width animates from 0 */}
<div
className="flex flex-col overflow-hidden transition-all duration-300 ease-out"
style={{ width: chatStarted ? "33.333%" : "0%" }}
>
{chatStarted && (
<PricingChatPanel
messages={messages}
input={input}
onInputChange={setInput}
onSubmit={handleSubmit}
isLoading={isLoading}
onViewJson={setJsonSheetConfig}
placeholder="Describe changes to your pricing..."
className="flex-1"
inputClassName="px-0 pb-0 mr-4"
/>
)}
</div>
{/* Right: Pricing Preview - shrinks as chat expands */}
<div className="flex-1 flex flex-col transition-all duration-300 ease-out">
<PricingPreview
config={displayConfig}
initialConfig={initialConfig}
previewOrg={previewOrg}
isSyncing={isPreviewSyncing}
headerActions={
displayConfig &&
displayConfig.products.length > 0 &&
chatStarted && (
<>
<Button
variant="secondary"
size="sm"
onClick={handleStartNewChat}
>
Reset
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleSave({ config: displayConfig })}
isLoading={isSaving}
>
Save changes
</Button>
</>
)
}
/>
{/* Floating input when chat hasn't started */}
{!chatStarted && (
<div className="absolute inset-0 flex items-end justify-center pb-16 pointer-events-none">
<div className="pointer-events-auto w-full max-w-md">
<CompactPromptInput
value={input}
onChange={setInput}
onSubmit={() => handleSubmit({ text: input, files: [] })}
placeholder="Describe changes to your pricing..."
isLoading={isLoading}
/>
</div>
</div>
)}
</div>
</div>
<PricingConfigSheet
open={jsonSheetConfig !== null}
onOpenChange={(open) => !open && setJsonSheetConfig(null)}
config={jsonSheetConfig}
/>
<ConfirmBatchVersionDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
productIds={versionedProductIds}
onConfirm={handleConfirmSave}
/>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { CubeIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
interface ProductsPageHeaderProps {
children?: ReactNode;
}
/**
* Shared header for the Products/Plans page.
* Matches Table.Toolbar + Table.Heading styles.
*/
export function ProductsPageHeader({ children }: ProductsPageHeaderProps) {
return (
<div className="flex flex-wrap items-center gap-2 h-10 pb-4">
<div className="flex w-full justify-between items-center">
<div className="text-t2 text-md py-0 px-2 rounded-lg flex gap-2 items-center">
<CubeIcon size={16} weight="fill" className="text-subtle" />
Plans
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { ListBulletsIcon, SquareHalfIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
export type ProductsViewMode = "list" | "ai";
interface ProductsViewToggleProps {
value: ProductsViewMode;
onValueChange: (value: ProductsViewMode) => void;
className?: string;
}
/**
* Icon toggle to switch between list view and AI chat view
*/
export function ProductsViewToggle({
value,
onValueChange,
className,
}: ProductsViewToggleProps) {
const options: Array<{
value: ProductsViewMode;
icon: React.ReactNode;
title: string;
}> = [
{
value: "list",
icon: <ListBulletsIcon className="size-3.5" />,
title: "List view",
},
{
value: "ai",
icon: <SquareHalfIcon className="size-3.5" />,
title: "AI assistant",
},
];
return (
<div className={cn("flex items-center", className)}>
{options.map((option, index) => {
const isActive = value === option.value;
const isFirst = index === 0;
const isLast = index === options.length - 1;
return (
<button
key={option.value}
type="button"
title={option.title}
onClick={() => onValueChange(option.value)}
className={cn(
"flex items-center justify-center p-1.5 h-7 w-7 border transition-none outline-none text-t3",
"hover:text-primary focus-visible:text-primary",
isActive && "text-primary bg-white dark:bg-zinc-900",
!isActive && "bg-zinc-100 dark:bg-zinc-800",
isFirst && "rounded-l-md border-l",
!isFirst && "border-l-0",
isLast && "rounded-r-md",
)}
>
{option.icon}
</button>
);
})}
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { isOneOffProductV2, type ProductV2 } from "@autumn/shared";
import { CubeIcon } from "@phosphor-icons/react";
import type { SortingState } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { Table } from "@/components/general/table";
@@ -11,7 +10,6 @@ import { useProductsQueryState } from "@/views/products/hooks/useProductsQuerySt
import { useProductTable } from "@/views/products/hooks/useProductTable";
import { createProductListColumns } from "./ProductListColumns";
import { ProductListCreateButton } from "./ProductListCreateButton";
import { ProductListMenuButton } from "./ProductListMenuButton";
type ProductWithCounts = ProductV2 & {
active_count?: number;
@@ -154,47 +152,31 @@ export function ProductListTable() {
<div className="flex flex-col gap-8">
{showTableStructure ? (
<>
{/* Recurring Plans Section */}
<div>
<Table.Provider
config={{
table: recurringBaseTable,
numberOfColumns: columns.length,
enableSorting,
isLoading: isCountsLoading,
getRowHref,
emptyStateText: queryStates.showArchivedProducts
? "You haven't archived any plans yet"
: "You haven't created any main plans yet",
rowClassName: "h-10",
}}
>
<Table.Toolbar>
<div className="flex w-full justify-between items-center">
<Table.Heading>
<CubeIcon size={16} weight="fill" className="text-subtle" />
Recurring Plans
</Table.Heading>
<Table.Actions>
<div className="flex w-full justify-between items-center">
<div className="flex items-center gap-2">
<ProductListCreateButton />
<ProductListMenuButton />
</div>
</div>
</Table.Actions>
</div>
</Table.Toolbar>
<Table.Container>
{hasRecurringAddOns && <SectionTag>Main Plans</SectionTag>}
<Table.Content>
<Table.Header />
<Table.Body />
</Table.Content>
</Table.Container>
</Table.Provider>
{/* Plans Section */}
<div>
<Table.Provider
config={{
table: recurringBaseTable,
numberOfColumns: columns.length,
enableSorting,
isLoading: isCountsLoading,
getRowHref,
emptyStateText: queryStates.showArchivedProducts
? "You haven't archived any plans yet"
: "Recurring plans that bill customers on a regular schedule",
rowClassName: "h-10",
}}
>
<Table.Container>
<SectionTag>Subscriptions</SectionTag>
<Table.Content>
<Table.Header />
<Table.Body />
</Table.Content>
</Table.Container>
</Table.Provider>
{/* Recurring Add-ons (only shown if add-ons exist) */}
{/* Add-on Plans (only shown when add-ons exist) */}
{hasRecurringAddOns && (
<Table.Provider
config={{
@@ -207,42 +189,35 @@ export function ProductListTable() {
}}
>
<Table.Container>
<SectionTag className="mt-4">Add-ons</SectionTag>
<SectionTag className="mt-4">Add-on subscriptions</SectionTag>
<Table.Content>
<Table.Body />
</Table.Content>
</Table.Container>
</Table.Provider>
)}
</div>
{/* One-Time Plans Section (only shown if one-time plans exist) */}
{hasOneTimePlans && (
<div>
<Table.Provider
config={{
table: oneTimeTable,
numberOfColumns: columns.length,
enableSorting,
isLoading: isCountsLoading,
getRowHref,
rowClassName: "h-10",
}}
>
<Table.Toolbar>
<Table.Heading>
<CubeIcon size={16} weight="fill" className="text-subtle" />
One-off Plans
</Table.Heading>
</Table.Toolbar>
<Table.Container>
<Table.Content>
<Table.Body />
</Table.Content>
</Table.Container>
</Table.Provider>
</div>
)}
{/* One-time Plans (always shown) */}
<Table.Provider
config={{
table: oneTimeTable,
numberOfColumns: columns.length,
enableSorting,
isLoading: isCountsLoading,
getRowHref,
emptyStateText:
"One-time prices for top-ups or lifetime purchases",
rowClassName: "h-10",
}}
>
<Table.Container>
<SectionTag className="mt-4">One-off purchases</SectionTag>
<Table.Content>
<Table.Body />
</Table.Content>
</Table.Container>
</Table.Provider>
</div>
</>
) : (
<EmptyState type="plans" actionButton={<ProductListCreateButton />} />

View File

@@ -0,0 +1,147 @@
import type { AgentPricingConfig, UpdateProductV2Params } from "@autumn/shared";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr, pushPage } from "@/utils/genUtils";
import {
getChangedProductIds,
getVersionedProductIds,
} from "@/views/onboarding4/preview/previewTypes";
import { updateProduct } from "@/views/products/product/utils/updateProduct";
export const usePricingConfigSave = ({
initialConfig,
}: {
initialConfig: AgentPricingConfig | null;
}) => {
const axiosInstance = useAxiosInstance();
const navigate = useNavigate();
const { org, mutate: mutateOrg } = useOrg();
const {
counts,
isCountsLoading,
refetch: refetchProducts,
} = useProductsQuery();
const [isSaving, setIsSaving] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [versionedProductIds, setVersionedProductIds] = useState<string[]>([]);
const [pendingConfig, setPendingConfig] = useState<AgentPricingConfig | null>(
null,
);
const executeSave = async ({ config }: { config: AgentPricingConfig }) => {
// Push new products/features (existing are skipped server-side)
await axiosInstance.post("/v1/configs/push", {
features: config.features,
products: config.products,
});
const changedIds = getChangedProductIds({
initialConfig,
currentConfig: config,
});
const existingIds = new Set(initialConfig?.products.map((p) => p.id) ?? []);
for (const productId of changedIds) {
if (!existingIds.has(productId)) continue;
const agentProduct = config.products.find((p) => p.id === productId);
if (!agentProduct) continue;
await updateProduct({
axiosInstance,
productId,
product: {
id: agentProduct.id,
name: agentProduct.name,
is_add_on: agentProduct.is_add_on,
is_default: agentProduct.is_default,
group: agentProduct.group || null, // empty string → null
items: agentProduct.items ?? [],
free_trial: agentProduct.free_trial ?? null,
} satisfies UpdateProductV2Params,
onSuccess: async () => {},
});
}
};
const handleSave = async ({
config,
}: {
config: AgentPricingConfig | null;
}) => {
if (!config) return;
if (isCountsLoading) {
toast.error("Plan counts are loading");
return;
}
setIsSaving(true);
try {
const versionedIds = getVersionedProductIds({
initialConfig,
currentConfig: config,
productCounts: counts,
});
if (versionedIds.length > 0) {
setVersionedProductIds(versionedIds);
setPendingConfig(config);
setConfirmOpen(true);
return;
}
await executeSave({ config });
toast.success("Changes saved successfully");
await refetchProducts();
if (!org?.onboarded) {
await axiosInstance.patch("/v1/organization", { onboarded: true });
await mutateOrg();
}
pushPage({ path: "/products", navigate });
} catch (error) {
console.error("Error saving changes:", error);
toast.error(getBackendErr(error, "Failed to save changes"));
} finally {
setIsSaving(false);
}
};
const handleConfirmSave = async () => {
if (!pendingConfig) return;
setIsSaving(true);
try {
await executeSave({ config: pendingConfig });
toast.success("Changes saved successfully");
await refetchProducts();
if (!org?.onboarded) {
await axiosInstance.patch("/v1/organization", { onboarded: true });
await mutateOrg();
}
pushPage({ path: "/products", navigate });
setConfirmOpen(false);
setPendingConfig(null);
} catch (error) {
console.error("Error saving changes:", error);
toast.error(getBackendErr(error, "Failed to save changes"));
} finally {
setIsSaving(false);
}
};
return {
isSaving,
confirmOpen,
setConfirmOpen,
versionedProductIds,
handleSave,
handleConfirmSave,
};
};