From da58544e1938b94fcb0f8402b99ebf43e628c4a1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Oct 2026 11:10:37 +0000 Subject: [PATCH 1/2] docs(api): update API changelog from spec diff [skip ci] --- developer-portal/docs/changelog.md | 4 + spec/openapi.previous.json | 1710 ++++++++++++++++++++++++++++ 2 files changed, 1714 insertions(+) create mode 100644 spec/openapi.previous.json diff --git a/developer-portal/docs/changelog.md b/developer-portal/docs/changelog.md index 6f950c5d..1a4f3535 100644 --- a/developer-portal/docs/changelog.md +++ b/developer-portal/docs/changelog.md @@ -1,5 +1,9 @@ # Changelog +## [1.0.0] - 2026-10-19 + +_No API changes detected._ + All notable changes to the SubTrackr API and SDKs will be documented in this file. ## [1.2.0] - 2026-07-24 diff --git a/spec/openapi.previous.json b/spec/openapi.previous.json new file mode 100644 index 00000000..2faf40dc --- /dev/null +++ b/spec/openapi.previous.json @@ -0,0 +1,1710 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "SubTrackr API", + "version": "1.0.0", + "description": "API specification for SubTrackr subscription management, billing, webhooks, and payments.\n\nBase paths are versioned under `/v1` and authenticated with a Bearer token (`sk_test_...` for sandbox, `sk_live_...` for production).", + "contact": { + "name": "SubTrackr Support", + "email": "support@subtrackr.io" + }, + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "https://api.subtrackr.io/v1", + "description": "Production Server" + }, + { + "url": "https://sandbox.api.subtrackr.io/v1", + "description": "Sandbox Server" + }, + { + "url": "http://localhost:3000/v1", + "description": "Local Development Server" + } + ], + "tags": [ + { + "name": "Auth", + "description": "Authentication" + }, + { + "name": "Subscriptions", + "description": "Manage subscriptions" + }, + { + "name": "Plans", + "description": "Plans and pricing" + }, + { + "name": "Payments", + "description": "Payment records" + }, + { + "name": "Invoices", + "description": "Invoices and billing" + }, + { + "name": "Webhooks", + "description": "Webhook configuration and delivery" + }, + { + "name": "Analytics", + "description": "Usage and subscription analytics" + }, + { + "name": "Themes", + "description": "Theme management for merchants" + } + ], + "paths": { + "/auth/login": { + "post": { + "summary": "Authenticate user", + "operationId": "loginUser", + "tags": ["Auth"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string" + } + }, + "required": ["email", "password"], + "example": { + "email": "dev@example.com", + "password": "secret" + } + } + } + } + }, + "responses": { + "200": { + "description": "Authentication successful", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "expiresIn": { + "type": "integer" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/subscriptions": { + "get": { + "summary": "List subscriptions", + "operationId": "listSubscriptions", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": ["active", "paused", "cancelled", "past_due", "trialing"] + } + }, + { + "name": "customerId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "planId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Opaque pagination cursor" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "List of subscriptions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionList" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "summary": "Create subscription", + "operationId": "createSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionInput" + } + } + } + }, + "responses": { + "201": { + "description": "Subscription created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/subscriptions/{id}": { + "get": { + "summary": "Get subscription", + "operationId": "getSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Subscription", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "put": { + "summary": "Update subscription", + "operationId": "updateSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Updated subscription", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + }, + "delete": { + "summary": "Delete subscription", + "operationId": "deleteSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Subscription deleted" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/subscriptions/{id}/cancel": { + "post": { + "summary": "Cancel subscription", + "operationId": "cancelSubscription", + "tags": ["Subscriptions"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubscriptionCancel" + } + } + } + }, + "responses": { + "200": { + "description": "Subscription cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Subscription" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/plans": { + "get": { + "summary": "List plans", + "operationId": "listPlans", + "tags": ["Plans"], + "parameters": [ + { + "name": "active", + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "currency", + "in": "query", + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 3 + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Opaque pagination cursor" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + } + ], + "responses": { + "200": { + "description": "List of plans with cursor pagination", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["success", "data", "meta"], + "properties": { + "success": { + "type": "boolean", + "const": true + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plan" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + }, + "meta": { + "$ref": "#/components/schemas/Meta" + } + } + } + } + } + } + } + }, + "post": { + "summary": "Create plan", + "operationId": "createPlan", + "tags": ["Plans"], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanInput" + } + } + } + }, + "responses": { + "201": { + "description": "Plan created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/plans/{id}": { + "get": { + "summary": "Get plan by ID", + "operationId": "getPlanById", + "tags": ["Plans"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Plan details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "patch": { + "summary": "Update plan", + "operationId": "updatePlan", + "tags": ["Plans"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Updated plan", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Plan" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/payments": { + "get": { + "summary": "List payments", + "operationId": "listPayments", + "tags": ["Payments"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "customerId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": ["succeeded", "failed", "pending"] + } + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "List of payments", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Payment" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "summary": "Record or initiate payment", + "operationId": "recordPayment", + "tags": ["Payments"], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentInput" + } + } + } + }, + "responses": { + "201": { + "description": "Payment recorded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Payment" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/invoices": { + "get": { + "summary": "List invoices", + "operationId": "listInvoices", + "tags": ["Invoices"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "subscriptionId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": ["paid", "open", "void"] + } + } + ], + "responses": { + "200": { + "description": "List of invoices", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invoice" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/webhooks": { + "get": { + "summary": "List webhooks", + "operationId": "listWebhooks", + "tags": ["Webhooks"], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List of webhooks", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Webhook" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "summary": "Create webhook endpoint", + "operationId": "createWebhook", + "tags": ["Webhooks"], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookInput" + } + } + } + }, + "responses": { + "201": { + "description": "Webhook created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Webhook" + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationError" + } + } + } + }, + "/analytics/usage": { + "get": { + "summary": "Get usage analytics", + "operationId": "getUsageAnalytics", + "tags": ["Analytics"], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "startDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "endDate", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "granularity", + "in": "query", + "schema": { + "type": "string", + "enum": ["hour", "day", "week", "month"], + "default": "day" + } + } + ], + "responses": { + "200": { + "description": "Usage analytics data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "period": { + "type": "string" + }, + "totalRequests": { + "type": "integer" + }, + "activeSubscriptions": { + "type": "integer" + }, + "revenue": { + "type": "number" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/themes": { + "get": { + "summary": "Get theme settings", + "operationId": "getThemes", + "tags": ["Themes"], + "responses": { + "200": { + "description": "Theme settings", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Theme" + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + }, + "schemas": { + "Subscription": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "planId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["active", "paused", "cancelled", "past_due", "trialing"] + }, + "currentPeriodStart": { + "type": "string", + "format": "date-time" + }, + "currentPeriodEnd": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "SubscriptionInput": { + "type": "object", + "properties": { + "customerId": { + "type": "string", + "pattern": "^0x[a-fA-F0-9]{40}$" + }, + "planId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "paymentMethodId": { + "type": "string", + "minLength": 1 + }, + "trialEnd": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["customerId", "planId", "paymentMethodId"], + "additionalProperties": false + }, + "SubscriptionUpdate": { + "type": "object", + "properties": { + "planId": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "paymentMethodId": { + "type": "string", + "minLength": 1 + }, + "cancelAtPeriodEnd": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "SubscriptionCancel": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 500 + }, + "feedback": { + "type": "string", + "maxLength": 2000 + }, + "immediate": { + "default": false, + "type": "boolean" + } + }, + "required": ["immediate"], + "additionalProperties": false + }, + "SubscriptionPause": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "maxLength": 500 + }, + "resumeAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "pauseDurationDays": { + "type": "integer", + "minimum": 1, + "maximum": 365 + } + }, + "additionalProperties": false + }, + "SubscriptionList": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Subscription" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + } + }, + "Pagination": { + "type": "object", + "properties": { + "nextCursor": { + "type": ["string", "null"] + }, + "hasMore": { + "type": "boolean" + }, + "limit": { + "type": "integer" + } + } + }, + "Meta": { + "type": "object", + "required": ["timestamp", "requestId", "apiVersion"], + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "apiVersion": { + "type": "string" + } + } + }, + "StandardErrorEnvelope": { + "type": "object", + "required": ["success", "error", "meta"], + "properties": { + "success": { + "type": "boolean", + "const": false + }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": true + }, + "version": { + "type": "string" + } + } + }, + "meta": { + "type": "object", + "required": ["timestamp", "requestId", "apiVersion"], + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "requestId": { + "type": "string" + }, + "apiVersion": { + "type": "string" + } + } + } + } + }, + "Plan": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "interval": { + "type": "string", + "enum": ["day", "week", "month", "year"] + }, + "active": { + "type": "boolean" + } + } + }, + "PlanInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "interval": { + "type": "string", + "enum": ["day", "week", "month", "year"] + }, + "intervalCount": { + "default": 1, + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "trialPeriodDays": { + "type": "integer", + "minimum": 0, + "maximum": 365 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "active": { + "default": true, + "type": "boolean" + } + }, + "required": ["name", "amount", "currency", "interval", "intervalCount", "active"], + "additionalProperties": false + }, + "PlanUpdate": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "interval": { + "type": "string", + "enum": ["day", "week", "month", "year"] + }, + "intervalCount": { + "default": 1, + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "trialPeriodDays": { + "type": "integer", + "minimum": 0, + "maximum": 365 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "active": { + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "Payment": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["succeeded", "failed", "pending"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PaymentInput": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "currency": { + "type": "string", + "minLength": 3, + "maxLength": 3 + }, + "customerId": { + "type": "string", + "pattern": "^0x[a-fA-F0-9]{40}$" + }, + "paymentMethodId": { + "type": "string", + "minLength": 1 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "gatewayPreference": { + "type": "string", + "enum": ["stripe", "circle", "stellar"] + } + }, + "required": ["amount", "currency", "customerId", "paymentMethodId", "idempotencyKey"], + "additionalProperties": false + }, + "RefundInput": { + "type": "object", + "properties": { + "chargeId": { + "type": "string", + "minLength": 1 + }, + "amount": { + "type": "number", + "exclusiveMinimum": 0 + }, + "reason": { + "type": "string", + "maxLength": 500 + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["chargeId"], + "additionalProperties": false + }, + "Invoice": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "subscriptionId": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["paid", "open", "void"] + }, + "dueDate": { + "type": "string", + "format": "date-time" + } + } + }, + "Webhook": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string", + "enum": ["enabled", "disabled"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "WebhookInput": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "events": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "secret": { + "type": "string", + "minLength": 16, + "maxLength": 255 + }, + "active": { + "default": true, + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["url", "events", "active"], + "additionalProperties": false + }, + "ApiKeyInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "tier": { + "default": "free", + "type": "string", + "enum": ["free", "basic", "pro", "enterprise"] + }, + "rateLimit": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["name", "tier"], + "additionalProperties": false + }, + "AnalyticsQuery": { + "type": "object", + "properties": { + "startDate": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "endDate": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$" + }, + "granularity": { + "default": "day", + "type": "string", + "enum": ["hour", "day", "week", "month"] + }, + "metrics": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "revenue", + "subscribers", + "churn_rate", + "mrr", + "arr", + "ltv", + "conversion_rate" + ] + } + }, + "groupBy": { + "type": "string", + "enum": ["plan", "currency", "gateway", "country"] + } + }, + "required": ["granularity"], + "additionalProperties": false + }, + "ForecastRequest": { + "type": "object", + "properties": { + "horizon": { + "default": 3, + "type": "integer", + "minimum": 1, + "maximum": 24 + }, + "granularity": { + "default": "month", + "type": "string", + "enum": ["day", "week", "month"] + }, + "includeConfidence": { + "default": true, + "type": "boolean" + }, + "confidenceLevel": { + "default": 0.95, + "type": "number", + "minimum": 0.5, + "maximum": 0.99 + } + }, + "required": ["horizon", "granularity", "includeConfidence", "confidenceLevel"], + "additionalProperties": false + }, + "FallbackChain": { + "type": "object", + "properties": { + "merchantId": { + "type": "string", + "minLength": 1 + }, + "chain": { + "minItems": 2, + "maxItems": 5, + "type": "array", + "items": { + "type": "object", + "properties": { + "gateway": { + "type": "string", + "enum": ["stripe", "circle", "stellar"] + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "timeoutMs": { + "default": 5000, + "type": "integer", + "minimum": 100, + "maximum": 30000 + } + }, + "required": ["gateway", "priority", "enabled", "timeoutMs"], + "additionalProperties": false + } + }, + "retryAttempts": { + "default": 1, + "type": "integer", + "minimum": 0, + "maximum": 5 + }, + "retryDelayMs": { + "default": 1000, + "type": "integer", + "minimum": 100, + "maximum": 10000 + } + }, + "required": ["merchantId", "chain", "retryAttempts", "retryDelayMs"], + "additionalProperties": false + }, + "CorsPolicy": { + "type": "object", + "properties": { + "tenantId": { + "type": "string", + "minLength": 1 + }, + "allowedOrigins": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "allowCredentials": { + "default": false, + "type": "boolean" + }, + "exposedHeaders": { + "type": "array", + "items": { + "type": "string" + } + }, + "maxAge": { + "default": 86400, + "type": "integer", + "minimum": 0, + "maximum": 86400 + }, + "allowMethods": { + "default": ["GET", "POST", "PUT", "PATCH", "DELETE"], + "type": "array", + "items": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + } + }, + "allowHeaders": { + "default": ["Content-Type", "Authorization"], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "tenantId", + "allowedOrigins", + "allowCredentials", + "maxAge", + "allowMethods", + "allowHeaders" + ], + "additionalProperties": false + }, + "Theme": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "primaryColor": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["active", "draft"] + } + } + } + }, + "responses": { + "Unauthorized": { + "description": "Missing or invalid authorization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + }, + "NotFound": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + }, + "BadRequest": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + }, + "ValidationError": { + "description": "Request payload validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardErrorEnvelope" + } + } + } + } + } + } +} From a764c8d39e2317d66b4e9fe4553f6d795854c199 Mon Sep 17 00:00:00 2001 From: clemscoder Date: Mon, 19 Oct 2026 12:31:14 +0000 Subject: [PATCH 2/2] feat(trials): add per-plan configurable trial periods with conversion (#1722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trials were hardcoded to 14 days globally. This PR makes trial length and auto-conversion configurable per subscription plan. Changes to backend/subscription/domain/trialManagementService.ts: - Add PlanTrialConfig interface (planId, trialDurationDays, autoConvertOnExpiry) - Add DEFAULT_TRIAL_DURATION_DAYS = 14 constant - Add planTrialConfigs Map to TrialManagementService - Add registerPlanTrialConfig() — register/replace a plan's trial policy; validates trialDurationDays is a positive finite number - Add getPlanTrialConfig() / listPlanTrialConfigs() accessors - Update startTrial() duration resolution order: 1. explicit durationDays param (call-site override) 2. registered PlanTrialConfig for the planId 3. DEFAULT_TRIAL_DURATION_DAYS (14) global fallback - Update processExpirations() to auto-convert instead of expiring when the plan's autoConvertOnExpiry is true; returns { converted, expired } so callers can act on each group; records trial_converted + payment_completed events on auto-convert Add backend/migrations/006_plan_trial_configs.sql: - plan_trial_configs table (plan_id FK, trial_duration_days CHECK > 0, auto_convert_on_expiry, description, timestamps) - auto-update trigger for updated_at - @down rollback section Update backend/subscription/__tests__/trialManagementService.test.ts: - Fix existing processExpirations test to destructure { expired, converted } - Add 18 new tests covering: registerPlanTrialConfig / getPlanTrialConfig (6 tests) startTrial per-plan duration resolution (4 tests) processExpirations auto-convert on expiry (8 tests) Tests: 36 passed, 36 total (18 existing + 18 new) Closes #1722 --- backend/migrations/006_plan_trial_configs.sql | 44 +++ .../__tests__/trialManagementService.test.ts | 256 +++++++++++++++++- .../domain/trialManagementService.ts | 114 +++++++- 3 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/006_plan_trial_configs.sql diff --git a/backend/migrations/006_plan_trial_configs.sql b/backend/migrations/006_plan_trial_configs.sql new file mode 100644 index 00000000..7339f8bd --- /dev/null +++ b/backend/migrations/006_plan_trial_configs.sql @@ -0,0 +1,44 @@ +-- Per-plan trial configuration for configurable trial periods (Issue #1722). +-- +-- Each row describes the trial policy for a specific subscription plan: +-- how long the trial lasts and whether it auto-converts to a paid +-- subscription on expiry. + +CREATE TABLE IF NOT EXISTS plan_trial_configs ( + plan_id TEXT PRIMARY KEY REFERENCES plans (id) ON DELETE CASCADE, + trial_duration_days INTEGER NOT NULL CHECK (trial_duration_days > 0), + auto_convert_on_expiry BOOLEAN NOT NULL DEFAULT false, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE plan_trial_configs IS + 'Per-plan trial configuration: duration in days and auto-conversion policy (Issue #1722).'; + +COMMENT ON COLUMN plan_trial_configs.trial_duration_days IS + 'Number of days the trial period lasts for this plan. Must be a positive integer.'; + +COMMENT ON COLUMN plan_trial_configs.auto_convert_on_expiry IS + 'When true the trial is automatically converted to a paid subscription at expiry ' + 'rather than being marked expired.'; + +-- Automatically update updated_at on row changes. +CREATE OR REPLACE FUNCTION update_plan_trial_configs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_plan_trial_configs_updated_at ON plan_trial_configs; +CREATE TRIGGER trg_plan_trial_configs_updated_at + BEFORE UPDATE ON plan_trial_configs + FOR EACH ROW + EXECUTE FUNCTION update_plan_trial_configs_updated_at(); + +-- @down +DROP TRIGGER IF EXISTS trg_plan_trial_configs_updated_at ON plan_trial_configs; +DROP FUNCTION IF EXISTS update_plan_trial_configs_updated_at(); +DROP TABLE IF EXISTS plan_trial_configs; diff --git a/backend/subscription/__tests__/trialManagementService.test.ts b/backend/subscription/__tests__/trialManagementService.test.ts index ec7bd306..bf84aa7a 100644 --- a/backend/subscription/__tests__/trialManagementService.test.ts +++ b/backend/subscription/__tests__/trialManagementService.test.ts @@ -198,7 +198,7 @@ describe('TrialManagementService', () => { }); describe('processExpirations', () => { - it('should expire trials past their end date', () => { + it('should expire trials past their end date when no auto-convert config', () => { const trial = service.startTrial({ subscriptionId: 'sub_001', userId: 'user_001', @@ -207,9 +207,10 @@ describe('TrialManagementService', () => { }); const future = Date.now() + 10 * 24 * 60 * 60 * 1000; - const expired = service.processExpirations(future); + const { expired, converted } = service.processExpirations(future); expect(expired).toHaveLength(1); + expect(converted).toHaveLength(0); expect(expired[0].status).toBe('expired'); expect(expired[0].id).toBe(trial.id); }); @@ -269,3 +270,254 @@ describe('TrialManagementService', () => { }); }); }); + +// ─── Per-plan trial configuration tests (Issue #1722) ───────────────────────── + +describe('registerPlanTrialConfig / getPlanTrialConfig', () => { + let service: TrialManagementService; + beforeEach(() => { service = new TrialManagementService(); }); + + it('registers a plan config and retrieves it', () => { + service.registerPlanTrialConfig({ + planId: 'enterprise', + trialDurationDays: 30, + autoConvertOnExpiry: true, + description: 'Enterprise 30-day trial', + }); + + const config = service.getPlanTrialConfig('enterprise'); + expect(config).toBeDefined(); + expect(config!.trialDurationDays).toBe(30); + expect(config!.autoConvertOnExpiry).toBe(true); + }); + + it('returns undefined for an unregistered plan', () => { + expect(service.getPlanTrialConfig('unknown-plan')).toBeUndefined(); + }); + + it('replaces an existing config on re-registration', () => { + service.registerPlanTrialConfig({ planId: 'basic', trialDurationDays: 7, autoConvertOnExpiry: false }); + service.registerPlanTrialConfig({ planId: 'basic', trialDurationDays: 14, autoConvertOnExpiry: true }); + + const config = service.getPlanTrialConfig('basic'); + expect(config!.trialDurationDays).toBe(14); + expect(config!.autoConvertOnExpiry).toBe(true); + }); + + it('throws for invalid trialDurationDays (zero)', () => { + expect(() => + service.registerPlanTrialConfig({ planId: 'bad', trialDurationDays: 0, autoConvertOnExpiry: false }), + ).toThrow('positive finite number'); + }); + + it('throws for invalid trialDurationDays (negative)', () => { + expect(() => + service.registerPlanTrialConfig({ planId: 'bad', trialDurationDays: -7, autoConvertOnExpiry: false }), + ).toThrow('positive finite number'); + }); + + it('lists all registered plan trial configs', () => { + service.registerPlanTrialConfig({ planId: 'basic', trialDurationDays: 7, autoConvertOnExpiry: false }); + service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 21, autoConvertOnExpiry: true }); + + const configs = service.listPlanTrialConfigs(); + expect(configs.length).toBeGreaterThanOrEqual(2); + expect(configs.map((c) => c.planId)).toContain('basic'); + expect(configs.map((c) => c.planId)).toContain('pro'); + }); +}); + +describe('startTrial – per-plan duration resolution', () => { + let service: TrialManagementService; + beforeEach(() => { service = new TrialManagementService(); }); + + it('uses plan config duration when no durationDays param is given', () => { + service.registerPlanTrialConfig({ + planId: 'enterprise', + trialDurationDays: 30, + autoConvertOnExpiry: false, + }); + + const trial = service.startTrial({ + subscriptionId: 'sub_100', + userId: 'user_100', + planId: 'enterprise', + }); + + expect(trial.durationDays).toBe(30); + // endDate should be ~30 days from now + const expectedEnd = trial.startDate + 30 * 24 * 60 * 60 * 1000; + expect(trial.endDate).toBe(expectedEnd); + }); + + it('explicit durationDays overrides plan config', () => { + service.registerPlanTrialConfig({ + planId: 'enterprise', + trialDurationDays: 30, + autoConvertOnExpiry: false, + }); + + const trial = service.startTrial({ + subscriptionId: 'sub_101', + userId: 'user_101', + planId: 'enterprise', + durationDays: 7, + }); + + expect(trial.durationDays).toBe(7); + }); + + it('falls back to DEFAULT_TRIAL_DURATION_DAYS (14) when no config and no param', () => { + const trial = service.startTrial({ + subscriptionId: 'sub_102', + userId: 'user_102', + planId: 'no-config-plan', + }); + + expect(trial.durationDays).toBe(14); + }); + + it('different plans get different durations from their configs', () => { + service.registerPlanTrialConfig({ planId: 'starter', trialDurationDays: 7, autoConvertOnExpiry: false }); + service.registerPlanTrialConfig({ planId: 'business', trialDurationDays: 21, autoConvertOnExpiry: false }); + + const starter = service.startTrial({ subscriptionId: 'sub_103', userId: 'u1', planId: 'starter' }); + const business = service.startTrial({ subscriptionId: 'sub_104', userId: 'u2', planId: 'business' }); + + expect(starter.durationDays).toBe(7); + expect(business.durationDays).toBe(21); + }); +}); + +describe('processExpirations – auto-convert on expiry', () => { + let service: TrialManagementService; + beforeEach(() => { service = new TrialManagementService(); }); + + const MS_DAY = 24 * 60 * 60 * 1000; + + it('auto-converts when plan config has autoConvertOnExpiry=true', () => { + service.registerPlanTrialConfig({ + planId: 'pro', + trialDurationDays: 14, + autoConvertOnExpiry: true, + }); + + const trial = service.startTrial({ + subscriptionId: 'sub_200', + userId: 'user_200', + planId: 'pro', + }); + + const future = trial.endDate + MS_DAY; // 1 day past end + const { converted, expired } = service.processExpirations(future); + + expect(converted).toHaveLength(1); + expect(expired).toHaveLength(0); + expect(converted[0]!.id).toBe(trial.id); + expect(converted[0]!.status).toBe('converted'); + expect(converted[0]!.conversionTrigger).toBe('automatic_time_based'); + expect(converted[0]!.convertedAt).toBeDefined(); + }); + + it('marks expired when plan config has autoConvertOnExpiry=false', () => { + service.registerPlanTrialConfig({ + planId: 'free', + trialDurationDays: 7, + autoConvertOnExpiry: false, + }); + + const trial = service.startTrial({ + subscriptionId: 'sub_201', + userId: 'user_201', + planId: 'free', + }); + + const future = trial.endDate + MS_DAY; + const { converted, expired } = service.processExpirations(future); + + expect(expired).toHaveLength(1); + expect(converted).toHaveLength(0); + expect(expired[0]!.status).toBe('expired'); + }); + + it('marks expired when no plan config is registered (default = no auto-convert)', () => { + const trial = service.startTrial({ + subscriptionId: 'sub_202', + userId: 'user_202', + planId: 'unregistered-plan', + durationDays: 7, + }); + + const future = trial.endDate + MS_DAY; + const { converted, expired } = service.processExpirations(future); + + expect(expired).toHaveLength(1); + expect(converted).toHaveLength(0); + }); + + it('handles mixed plans in one processExpirations pass', () => { + service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 7, autoConvertOnExpiry: true }); + service.registerPlanTrialConfig({ planId: 'free', trialDurationDays: 7, autoConvertOnExpiry: false }); + + const t1 = service.startTrial({ subscriptionId: 'sub_210', userId: 'u1', planId: 'pro' }); + const t2 = service.startTrial({ subscriptionId: 'sub_211', userId: 'u2', planId: 'free' }); + + const future = Math.max(t1.endDate, t2.endDate) + MS_DAY; + const { converted, expired } = service.processExpirations(future); + + expect(converted.map((t) => t.id)).toContain(t1.id); + expect(expired.map((t) => t.id)).toContain(t2.id); + }); + + it('does not touch trials that have not yet expired', () => { + service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 30, autoConvertOnExpiry: true }); + + const trial = service.startTrial({ + subscriptionId: 'sub_220', + userId: 'user_220', + planId: 'pro', + }); + + // Process right now — trial is still active + const { converted, expired } = service.processExpirations(trial.startDate); + + expect(converted).toHaveLength(0); + expect(expired).toHaveLength(0); + expect(service.getTrial(trial.id)!.status).toBe('active'); + }); + + it('does not re-process already-converted or expired trials', () => { + service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 7, autoConvertOnExpiry: true }); + + const trial = service.startTrial({ subscriptionId: 'sub_230', userId: 'u1', planId: 'pro' }); + const future = trial.endDate + MS_DAY; + + service.processExpirations(future); // first pass — converts + const second = service.processExpirations(future); // second pass — no-op + + expect(second.converted).toHaveLength(0); + expect(second.expired).toHaveLength(0); + }); + + it('records trial_converted and payment_completed events on auto-convert', () => { + service.registerPlanTrialConfig({ planId: 'pro', trialDurationDays: 7, autoConvertOnExpiry: true }); + + const trial = service.startTrial({ subscriptionId: 'sub_240', userId: 'u1', planId: 'pro' }); + service.processExpirations(trial.endDate + MS_DAY); + + const events = service.getFunnelEvents(trial.id); + expect(events.some((e) => e.eventType === 'trial_converted')).toBe(true); + expect(events.some((e) => e.eventType === 'payment_completed')).toBe(true); + }); + + it('records trial_expired event on normal expiry', () => { + service.registerPlanTrialConfig({ planId: 'free', trialDurationDays: 7, autoConvertOnExpiry: false }); + + const trial = service.startTrial({ subscriptionId: 'sub_250', userId: 'u1', planId: 'free' }); + service.processExpirations(trial.endDate + MS_DAY); + + const events = service.getFunnelEvents(trial.id); + expect(events.some((e) => e.eventType === 'trial_expired')).toBe(true); + expect(events.some((e) => e.eventType === 'trial_converted')).toBe(false); + }); +}); diff --git a/backend/subscription/domain/trialManagementService.ts b/backend/subscription/domain/trialManagementService.ts index 301c7a7e..353031dd 100644 --- a/backend/subscription/domain/trialManagementService.ts +++ b/backend/subscription/domain/trialManagementService.ts @@ -12,6 +12,36 @@ export type TrialStatus = 'active' | 'converted' | 'expired' | 'cancelled'; export type TrialDurationDays = 7 | 14 | 21 | 30; +// ─── Per-plan trial configuration ──────────────────────────────────────────── + +/** + * Trial length and conversion policy for a specific subscription plan. + * Registered via {@link TrialManagementService.registerPlanTrialConfig}. + * Falls back to the global DEFAULT_TRIAL_DURATION_DAYS when no config is found. + */ +export interface PlanTrialConfig { + /** The plan this config applies to. */ + planId: string; + /** + * How many days the trial lasts. + * Must be a positive integer. Constrained to TrialDurationDays for + * well-known values; the type is widened to `number` for custom durations. + */ + trialDurationDays: number; + /** + * When true, `processExpirations` automatically calls `convertTrial` instead + * of marking the trial expired. + */ + autoConvertOnExpiry: boolean; + /** + * Optional human-readable description shown in admin dashboards. + */ + description?: string; +} + +/** Fallback trial length when no per-plan config is registered. */ +export const DEFAULT_TRIAL_DURATION_DAYS = 14; + export type ConversionTrigger = | 'automatic_time_based' | 'feature_usage_threshold' @@ -133,9 +163,46 @@ export class TrialManagementService { private events: FunnelEvent[] = []; private extensionRules: TrialExtensionRule[] = [...DEFAULT_EXTENSION_RULES]; private conversionRevenue = 0; + /** Per-plan trial configuration registry. */ + private planTrialConfigs = new Map(); + + // ─── Per-plan trial config ────────────────────────────────────────────────── + + /** + * Register (or replace) a per-plan trial configuration. + * Call this at application bootstrap, e.g. alongside plan creation. + */ + registerPlanTrialConfig(config: PlanTrialConfig): void { + if (config.trialDurationDays <= 0 || !Number.isFinite(config.trialDurationDays)) { + throw new Error( + `PlanTrialConfig for plan "${config.planId}": trialDurationDays must be a positive finite number`, + ); + } + this.planTrialConfigs.set(config.planId, { ...config }); + } + + /** + * Retrieve the trial configuration for a plan. + * Returns `undefined` when no config has been registered. + */ + getPlanTrialConfig(planId: string): PlanTrialConfig | undefined { + return this.planTrialConfigs.get(planId); + } + + /** + * List all registered plan trial configs. + */ + listPlanTrialConfigs(): PlanTrialConfig[] { + return Array.from(this.planTrialConfigs.values()); + } /** * Start a new trial for a subscription. + * + * Duration resolution order: + * 1. Explicit `durationDays` param (call-site override) + * 2. Registered {@link PlanTrialConfig} for the given planId + * 3. Global {@link DEFAULT_TRIAL_DURATION_DAYS} (14 days) */ startTrial(params: { subscriptionId: string; @@ -151,8 +218,14 @@ export class TrialManagementService { ); } + // Resolve duration: explicit param > plan config > global default + const planConfig = this.planTrialConfigs.get(params.planId); + const durationDays = + params.durationDays ?? + planConfig?.trialDurationDays ?? + DEFAULT_TRIAL_DURATION_DAYS; + const now = Date.now(); - const durationDays = params.durationDays ?? 14; const endDate = now + durationDays * MS_PER_DAY; const trial: TrialRecord = { @@ -161,7 +234,7 @@ export class TrialManagementService { userId: params.userId, planId: params.planId, status: 'active', - durationDays, + durationDays: durationDays as TrialDurationDays, startDate: now, endDate, originalEndDate: endDate, @@ -331,13 +404,42 @@ export class TrialManagementService { } /** - * Process expired trials — mark them expired and record events. + * Process expired trials — mark them expired or auto-convert to paid. + * + * For each active trial whose `endDate <= now`: + * - If the plan has `autoConvertOnExpiry: true`, the trial is converted + * with trigger `'automatic_time_based'`. + * - Otherwise the trial is marked `'expired'`. + * + * Returns separate arrays so callers can take action on each group. */ - processExpirations(now: number = Date.now()): TrialRecord[] { + processExpirations(now: number = Date.now()): { + converted: TrialRecord[]; + expired: TrialRecord[]; + } { + const converted: TrialRecord[] = []; const expired: TrialRecord[] = []; for (const trial of this.trials.values()) { - if (trial.status === 'active' && trial.endDate <= now) { + if (trial.status !== 'active') continue; + if (trial.endDate > now) continue; + + const planConfig = this.planTrialConfigs.get(trial.planId); + const shouldAutoConvert = planConfig?.autoConvertOnExpiry ?? false; + + if (shouldAutoConvert) { + trial.status = 'converted'; + trial.convertedAt = now; + trial.conversionTrigger = 'automatic_time_based'; + trial.updatedAt = now; + this.recordEvent(trial.id, 'trial_converted', trial.userId, { + trigger: 'automatic_time_based', + }); + this.recordEvent(trial.id, 'payment_completed', trial.userId, { + trigger: 'automatic_time_based', + }); + converted.push(trial); + } else { trial.status = 'expired'; trial.updatedAt = now; this.recordEvent(trial.id, 'trial_expired', trial.userId); @@ -345,7 +447,7 @@ export class TrialManagementService { } } - return expired; + return { converted, expired }; } /**