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 af7b1872bd705063fac2bc392b0213d6504e93a6 Mon Sep 17 00:00:00 2001 From: clemscoder Date: Mon, 19 Oct 2026 11:56:38 +0000 Subject: [PATCH 2/2] feat(payments): add cross-account payment splitting (#1720) Split a payment across multiple destination accounts by percentage. - Add PaymentSplitService with validateSplits() enforcing sum=100 - Add PaymentSplitController (POST /api/v1/payments/split) - Returns 200 when all splits succeed, 207 on partial failure - Returns 422 when percentages do not sum to 100 - Add paymentSplitRouter and mount it in createApiServer - Add migration 005_payment_split_records.sql with payment_split_records + payment_split_entries tables - Add 46 passing tests covering validation, arithmetic, failure handling, idempotency key derivation, and HTTP responses Closes #1720 --- .../migrations/005_payment_split_records.sql | 56 ++ backend/server/createApiServer.ts | 8 + .../__tests__/PaymentSplitService.test.ts | 581 ++++++++++++++++++ .../controller/paymentSplitController.ts | 103 ++++ .../payment/domain/PaymentSplitService.ts | 217 +++++++ backend/services/payment/errors.ts | 1 + backend/services/payment/index.ts | 14 + backend/services/payment/interfaces.ts | 16 + .../payment/router/paymentSplitRouter.ts | 39 ++ 9 files changed, 1035 insertions(+) create mode 100644 backend/migrations/005_payment_split_records.sql create mode 100644 backend/services/payment/__tests__/PaymentSplitService.test.ts create mode 100644 backend/services/payment/controller/paymentSplitController.ts create mode 100644 backend/services/payment/domain/PaymentSplitService.ts create mode 100644 backend/services/payment/router/paymentSplitRouter.ts diff --git a/backend/migrations/005_payment_split_records.sql b/backend/migrations/005_payment_split_records.sql new file mode 100644 index 00000000..2f436a08 --- /dev/null +++ b/backend/migrations/005_payment_split_records.sql @@ -0,0 +1,56 @@ +-- Payment Split Records for cross-account payment splitting (Issue #1720). +-- +-- Stores the outcome of each individual charge within a split payment +-- for audit, revenue reconciliation, and dispute investigation. + +CREATE TABLE IF NOT EXISTS payment_split_records ( + id TEXT PRIMARY KEY, + -- The idempotency key of the parent split request. + idempotency_key TEXT NOT NULL, + -- Total amount of the parent split request, in smallest currency unit. + total_amount NUMERIC(20, 8) NOT NULL, + currency TEXT NOT NULL, + -- Whether every individual split charge succeeded. + all_succeeded BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_psr_idempotency_key ON payment_split_records (idempotency_key); +CREATE INDEX IF NOT EXISTS idx_psr_created_at ON payment_split_records (created_at); + +COMMENT ON TABLE payment_split_records IS + 'Top-level record for a cross-account split payment (Issue #1720). ' + 'Each row groups the per-destination entries in payment_split_entries.'; + +-- ─── Per-entry detail ──────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS payment_split_entries ( + id TEXT PRIMARY KEY, + split_record_id TEXT NOT NULL REFERENCES payment_split_records (id) ON DELETE CASCADE, + destination_account_id TEXT NOT NULL, + percentage NUMERIC(7, 4) NOT NULL, + amount NUMERIC(20, 8) NOT NULL, + status TEXT NOT NULL CHECK (status IN ('succeeded', 'failed', 'pending')), + charge_id TEXT, + gateway_used TEXT, + error_message TEXT, + processed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_pse_split_record_id ON payment_split_entries (split_record_id); +CREATE INDEX IF NOT EXISTS idx_pse_destination_account ON payment_split_entries (destination_account_id); +CREATE INDEX IF NOT EXISTS idx_pse_status ON payment_split_entries (status); + +COMMENT ON TABLE payment_split_entries IS + 'Per-destination charge result within a split payment. ' + 'One row per PaymentSplitEntry in the original request.'; + +COMMENT ON COLUMN payment_split_entries.percentage IS 'Percentage of the total amount directed to this destination (0–100).'; +COMMENT ON COLUMN payment_split_entries.amount IS 'Actual amount charged to this destination, in smallest currency unit.'; +COMMENT ON COLUMN payment_split_entries.charge_id IS 'Gateway-returned charge/transaction identifier.'; +COMMENT ON COLUMN payment_split_entries.error_message IS 'Non-null only when status = ''failed''.'; + +-- @down +DROP TABLE IF EXISTS payment_split_entries; +DROP TABLE IF EXISTS payment_split_records; diff --git a/backend/server/createApiServer.ts b/backend/server/createApiServer.ts index 1b8c6fa7..223e5bf8 100644 --- a/backend/server/createApiServer.ts +++ b/backend/server/createApiServer.ts @@ -61,6 +61,9 @@ import { PaddleAdapter } from '../services/payment/domain/gateways/PaddleAdapter import { PaddleWebhookVerifier } from '../services/payment/domain/paddle/PaddleWebhookVerifier'; import { createPaddleRouter } from '../services/payment/router/paddleRouter'; +// ── Payment splitting (issue #1720) ─────────────────────────────────────────── +import { createPaymentSplitRouter } from '../services/payment/router/paymentSplitRouter'; + // ── Calendar + renewal reminders ────────────────────────────────────────────── import { calendarSyncService } from '../calendar/domain/CalendarSyncService'; import { createCalendarSyncController } from '../calendar/controller/calendarSyncController'; @@ -310,6 +313,11 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express { }), ); + // ── Payment splitting (issue #1720) ──────────────────────────────────────── + // POST /api/v1/payments/split — split a charge across multiple destination accounts. + // The router itself adds the /payments prefix to the route path. + app.use('/api/v1', createPaymentSplitRouter()); + // ── Webhook signature verification demo endpoint ─────────────────────────── // Uses captureRawBody only on this specific route so the stream isn't // consumed before express.json() processes all other routes. diff --git a/backend/services/payment/__tests__/PaymentSplitService.test.ts b/backend/services/payment/__tests__/PaymentSplitService.test.ts new file mode 100644 index 00000000..2e906b36 --- /dev/null +++ b/backend/services/payment/__tests__/PaymentSplitService.test.ts @@ -0,0 +1,581 @@ +/** + * Tests for PaymentSplitService (Issue #1720 – cross-account payment splitting). + * + * Covers: + * - validateSplits: valid splits, percentage-sum enforcement, edge cases + * - splitAmount: allocation arithmetic including last-entry rounding + * - PaymentSplitService.split(): happy path, partial failure, all-failure, + * idempotency-key derivation, metadata forwarding + * - PaymentSplitController.split(): HTTP request parsing, 200 / 207 / 400 / 422 / 500 + */ + +import type { Request, Response } from 'express'; +import { + PaymentSplitService, + validateSplits, + splitAmount, + type SplitPaymentRequest, + type PaymentSplitEntry, +} from '../domain/PaymentSplitService'; +import { PaymentSplitController } from '../controller/paymentSplitController'; +import { PaymentError, PaymentErrorCode } from '../errors'; +import type { IPaymentRouter, PaymentRequest, PaymentResult } from '../interfaces'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeResult(overrides: Partial = {}): PaymentResult { + return { + id: 'charge_test', + status: 'succeeded', + amount: 100, + currency: 'USD', + gatewayUsed: 'stripe', + chargeId: 'charge_test', + processedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeRouter(chargeFn: (req: PaymentRequest) => Promise): IPaymentRouter { + return { + charge: chargeFn, + refund: jest.fn(), + getGateway: jest.fn(), + registerGateway: jest.fn(), + }; +} + +/** Minimal valid two-way split (50 / 50). */ +function twoWaySplit(): PaymentSplitEntry[] { + return [ + { destinationAccountId: 'acct_A', percentage: 50 }, + { destinationAccountId: 'acct_B', percentage: 50 }, + ]; +} + +function buildRequest(overrides: Partial = {}): SplitPaymentRequest { + return { + amount: 200, + currency: 'USD', + idempotencyKey: 'idem_key_1', + splits: twoWaySplit(), + ...overrides, + }; +} + +// ─── validateSplits ─────────────────────────────────────────────────────────── + +describe('validateSplits', () => { + it('accepts a valid two-entry split summing to exactly 100', () => { + expect(() => validateSplits(twoWaySplit())).not.toThrow(); + }); + + it('accepts a three-entry split summing to exactly 100', () => { + const splits: PaymentSplitEntry[] = [ + { destinationAccountId: 'a', percentage: 30 }, + { destinationAccountId: 'b', percentage: 40 }, + { destinationAccountId: 'c', percentage: 30 }, + ]; + expect(() => validateSplits(splits)).not.toThrow(); + }); + + it('accepts fractional percentages that sum to 100 within tolerance', () => { + // 33.33 + 33.33 + 33.34 = 100 exactly. + const splits: PaymentSplitEntry[] = [ + { destinationAccountId: 'a', percentage: 33.33 }, + { destinationAccountId: 'b', percentage: 33.33 }, + { destinationAccountId: 'c', percentage: 33.34 }, + ]; + expect(() => validateSplits(splits)).not.toThrow(); + }); + + it('throws SPLIT_VALIDATION_ERROR when fewer than 2 entries', () => { + expect(() => validateSplits([{ destinationAccountId: 'a', percentage: 100 }])).toThrow( + PaymentError, + ); + try { + validateSplits([{ destinationAccountId: 'a', percentage: 100 }]); + } catch (e) { + expect((e as PaymentError).code).toBe(PaymentErrorCode.SPLIT_VALIDATION_ERROR); + } + }); + + it('throws SPLIT_VALIDATION_ERROR for an empty array', () => { + expect(() => validateSplits([])).toThrow(PaymentError); + }); + + it('throws SPLIT_VALIDATION_ERROR when a destinationAccountId is blank', () => { + expect(() => + validateSplits([ + { destinationAccountId: ' ', percentage: 50 }, + { destinationAccountId: 'b', percentage: 50 }, + ]), + ).toThrow(PaymentError); + }); + + it('throws SPLIT_VALIDATION_ERROR when a percentage is 0', () => { + expect(() => + validateSplits([ + { destinationAccountId: 'a', percentage: 0 }, + { destinationAccountId: 'b', percentage: 100 }, + ]), + ).toThrow(PaymentError); + }); + + it('throws SPLIT_VALIDATION_ERROR when a percentage is negative', () => { + expect(() => + validateSplits([ + { destinationAccountId: 'a', percentage: -10 }, + { destinationAccountId: 'b', percentage: 110 }, + ]), + ).toThrow(PaymentError); + }); + + it('throws SPLIT_VALIDATION_ERROR when percentages sum to less than 100', () => { + expect(() => + validateSplits([ + { destinationAccountId: 'a', percentage: 40 }, + { destinationAccountId: 'b', percentage: 40 }, + ]), + ).toThrow(PaymentError); + }); + + it('throws SPLIT_VALIDATION_ERROR when percentages sum to more than 100', () => { + expect(() => + validateSplits([ + { destinationAccountId: 'a', percentage: 60 }, + { destinationAccountId: 'b', percentage: 60 }, + ]), + ).toThrow(PaymentError); + }); + + it('includes the actual sum in the error message', () => { + expect(() => + validateSplits([ + { destinationAccountId: 'a', percentage: 30 }, + { destinationAccountId: 'b', percentage: 30 }, + ]), + ).toThrow('60'); + }); +}); + +// ─── splitAmount ────────────────────────────────────────────────────────────── + +describe('splitAmount', () => { + it('calculates a non-last split to 2 decimal places', () => { + expect(splitAmount(200, 50, false, 0)).toBe(100); + }); + + it('gives the last entry the remainder to avoid drift', () => { + // 3-way split of 100: 33.33 + 33.33 = 66.66 allocated; last gets 33.34 + const first = splitAmount(100, 33.33, false, 0); + const second = splitAmount(100, 33.33, false, first); + const third = splitAmount(100, 33.34, true, first + second); + expect(first + second + third).toBeCloseTo(100, 5); + // Last entry absorbs the penny of drift + expect(third).toBeCloseTo(33.34, 2); + }); + + it('returns 0 for the last entry when there is no remaining amount', () => { + // Already allocated 100% of a 100-unit payment + expect(splitAmount(100, 50, true, 100)).toBe(0); + }); + + it('handles a 1-cent split correctly', () => { + expect(splitAmount(0.01, 50, false, 0)).toBe(0.01); + }); +}); + +// ─── PaymentSplitService ────────────────────────────────────────────────────── + +describe('PaymentSplitService', () => { + let chargeRequests: PaymentRequest[]; + + beforeEach(() => { + chargeRequests = []; + }); + + function makeService( + handler: (req: PaymentRequest) => Promise = async (req) => + makeResult({ amount: req.amount, currency: req.currency }), + ): PaymentSplitService { + return new PaymentSplitService( + makeRouter(async (req) => { + chargeRequests.push(req); + return handler(req); + }), + ); + } + + // ── Happy path ───────────────────────────────────────────────────────────── + + describe('successful splits', () => { + it('returns allSucceeded=true when all splits succeed', async () => { + const service = makeService(); + const result = await service.split(buildRequest()); + expect(result.allSucceeded).toBe(true); + }); + + it('produces one charge per split entry', async () => { + const service = makeService(); + const splits: PaymentSplitEntry[] = [ + { destinationAccountId: 'acct_1', percentage: 30 }, + { destinationAccountId: 'acct_2', percentage: 40 }, + { destinationAccountId: 'acct_3', percentage: 30 }, + ]; + await service.split(buildRequest({ amount: 1000, splits })); + expect(chargeRequests).toHaveLength(3); + }); + + it('distributes the total amount proportionally across all splits', async () => { + const service = makeService(); + await service.split(buildRequest({ amount: 1000, splits: twoWaySplit() })); + expect(chargeRequests[0]!.amount).toBe(500); + expect(chargeRequests[1]!.amount).toBe(500); + }); + + it('ensures amounts sum exactly to total (last-entry remainder adjustment)', async () => { + const service = makeService(); + const splits: PaymentSplitEntry[] = [ + { destinationAccountId: 'a', percentage: 33.33 }, + { destinationAccountId: 'b', percentage: 33.33 }, + { destinationAccountId: 'c', percentage: 33.34 }, + ]; + await service.split(buildRequest({ amount: 100, splits })); + const total = chargeRequests.reduce((sum, r) => sum + r.amount, 0); + expect(total).toBeCloseTo(100, 5); + }); + + it('populates split results with gateway and charge data', async () => { + const service = makeService(async (req) => + makeResult({ chargeId: `ch_${req.customerId}`, gatewayUsed: 'circle', amount: req.amount }), + ); + const result = await service.split(buildRequest()); + expect(result.splits[0]!.chargeId).toBe('ch_acct_A'); + expect(result.splits[0]!.gatewayUsed).toBe('circle'); + }); + + it('reflects the currency from the request in the result', async () => { + const service = makeService(); + const result = await service.split(buildRequest({ currency: 'XLM' })); + expect(result.currency).toBe('XLM'); + }); + + it('reflects totalAmount from the request in the result', async () => { + const service = makeService(); + const result = await service.split(buildRequest({ amount: 999 })); + expect(result.totalAmount).toBe(999); + }); + }); + + // ── Idempotency key derivation ───────────────────────────────────────────── + + describe('idempotency key derivation', () => { + it('derives per-split idempotency keys from the parent key', async () => { + const service = makeService(); + await service.split(buildRequest({ idempotencyKey: 'parent_key' })); + expect(chargeRequests[0]!.idempotencyKey).toBe('parent_key:split:0'); + expect(chargeRequests[1]!.idempotencyKey).toBe('parent_key:split:1'); + }); + }); + + // ── Metadata forwarding ──────────────────────────────────────────────────── + + describe('metadata and chain forwarding', () => { + it('forwards chainType to each charge', async () => { + const service = makeService(); + await service.split(buildRequest({ chainType: 'stellar' })); + for (const req of chargeRequests) { + expect(req.chainType).toBe('stellar'); + } + }); + + it('forwards chainId to each charge', async () => { + const service = makeService(); + await service.split(buildRequest({ chainId: 137 })); + for (const req of chargeRequests) { + expect(req.chainId).toBe(137); + } + }); + + it('forwards metadata to each charge', async () => { + const service = makeService(); + await service.split(buildRequest({ metadata: { orderId: 'ord_abc' } })); + for (const req of chargeRequests) { + expect(req.metadata?.orderId).toBe('ord_abc'); + } + }); + + it('does not attach chainType key when not supplied', async () => { + const service = makeService(); + await service.split(buildRequest({ chainType: undefined })); + for (const req of chargeRequests) { + expect(Object.prototype.hasOwnProperty.call(req, 'chainType')).toBe(false); + } + }); + }); + + // ── Partial / full failure ───────────────────────────────────────────────── + + describe('failure handling', () => { + it('continues remaining splits when one fails', async () => { + let callCount = 0; + const service = makeService(async (req) => { + callCount++; + if (callCount === 1) throw new Error('gateway timeout'); + return makeResult({ amount: req.amount }); + }); + + const result = await service.split(buildRequest()); + // Both splits were attempted despite the first one failing + expect(chargeRequests).toHaveLength(2); + expect(result.splits[0]!.status).toBe('failed'); + expect(result.splits[1]!.status).toBe('succeeded'); + expect(result.allSucceeded).toBe(false); + }); + + it('captures the error message on a failed split entry', async () => { + const service = makeService(async () => { + throw new Error('insufficient funds'); + }); + const result = await service.split(buildRequest()); + expect(result.splits[0]!.errorMessage).toBe('insufficient funds'); + }); + + it('returns allSucceeded=false when all splits fail', async () => { + const service = makeService(async () => { + throw new Error('all gateways down'); + }); + const result = await service.split(buildRequest()); + expect(result.allSucceeded).toBe(false); + expect(result.splits.every((s) => s.status === 'failed')).toBe(true); + }); + + it('records the destinationAccountId on failed entries', async () => { + const service = makeService(async () => { + throw new Error('fail'); + }); + const result = await service.split(buildRequest()); + expect(result.splits[0]!.destinationAccountId).toBe('acct_A'); + }); + }); + + // ── Validation delegation ────────────────────────────────────────────────── + + describe('validation delegation', () => { + it('throws before charging when percentages do not sum to 100', async () => { + const service = makeService(); + const badSplits: PaymentSplitEntry[] = [ + { destinationAccountId: 'a', percentage: 30 }, + { destinationAccountId: 'b', percentage: 30 }, + ]; + await expect(service.split(buildRequest({ splits: badSplits }))).rejects.toThrow(PaymentError); + expect(chargeRequests).toHaveLength(0); + }); + + it('throws before charging when fewer than 2 splits are supplied', async () => { + const service = makeService(); + await expect( + service.split( + buildRequest({ + splits: [{ destinationAccountId: 'a', percentage: 100 }], + }), + ), + ).rejects.toThrow(PaymentError); + expect(chargeRequests).toHaveLength(0); + }); + }); +}); + +// ─── PaymentSplitController ─────────────────────────────────────────────────── + +describe('PaymentSplitController', () => { + function makeRes(): { statusCode: number; body: unknown; status: jest.Mock; json: jest.Mock } { + let currentStatusCode = 200; + let currentBody: unknown = undefined; + + const res = { + get statusCode() { + return currentStatusCode; + }, + get body() { + return currentBody; + }, + } as { statusCode: number; body: unknown; status: jest.Mock; json: jest.Mock }; + + const jsonMock = jest.fn().mockImplementation((b: unknown) => { + currentBody = b; + return res; + }); + + const statusMock = jest.fn().mockImplementation((code: number) => { + currentStatusCode = code; + // Return an object that has a json method so chaining works. + return { json: jsonMock }; + }); + + (res as Record)['status'] = statusMock; + (res as Record)['json'] = jsonMock; + + return res as { statusCode: number; body: unknown; status: jest.Mock; json: jest.Mock }; + } + + function makeReq(body: Record = {}, headers: Record = {}): Request { + return { body, headers } as unknown as Request; + } + + function makeController( + handler?: (req: PaymentRequest) => Promise, + ): PaymentSplitController { + const router = makeRouter( + handler ?? + (async (req) => + makeResult({ amount: req.amount, currency: req.currency, chargeId: `ch_${req.customerId}` })), + ); + const service = new PaymentSplitService(router); + return new PaymentSplitController(service); + } + + const validBody = { + amount: 300, + currency: 'USD', + idempotencyKey: 'ik_ctrl_1', + splits: [ + { destinationAccountId: 'acct_X', percentage: 60 }, + { destinationAccountId: 'acct_Y', percentage: 40 }, + ], + }; + + // ── 200 OK ───────────────────────────────────────────────────────────────── + + it('returns 200 when all splits succeed', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split(makeReq(validBody), res as unknown as Response); + expect(res.statusCode).toBe(200); + expect((res.body as { success: boolean }).success).toBe(true); + }); + + it('includes split results in the response data', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split(makeReq(validBody), res as unknown as Response); + const data = (res.body as { data: { splits: unknown[] } }).data; + expect(data.splits).toHaveLength(2); + }); + + // ── 207 Multi-Status ─────────────────────────────────────────────────────── + + it('returns 207 when at least one split fails', async () => { + let firstCall = true; + const ctrl = makeController(async () => { + if (firstCall) { + firstCall = false; + throw new Error('gateway error'); + } + return makeResult(); + }); + const res = makeRes(); + await ctrl.split(makeReq(validBody), res as unknown as Response); + expect(res.statusCode).toBe(207); + }); + + // ── 400 / 422 validation rejections ─────────────────────────────────────── + + it('returns 400 when amount is missing', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split(makeReq({ ...validBody, amount: undefined }), res as unknown as Response); + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when amount is zero', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split(makeReq({ ...validBody, amount: 0 }), res as unknown as Response); + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when currency is missing', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split(makeReq({ ...validBody, currency: '' }), res as unknown as Response); + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when idempotencyKey is missing', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split(makeReq({ ...validBody, idempotencyKey: '' }), res as unknown as Response); + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when splits array is missing', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split(makeReq({ ...validBody, splits: undefined }), res as unknown as Response); + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when splits is an empty array', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split(makeReq({ ...validBody, splits: [] }), res as unknown as Response); + expect(res.statusCode).toBe(400); + }); + + it('returns 422 when split percentages do not sum to 100', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split( + makeReq({ + ...validBody, + splits: [ + { destinationAccountId: 'a', percentage: 30 }, + { destinationAccountId: 'b', percentage: 30 }, + ], + }), + res as unknown as Response, + ); + expect(res.statusCode).toBe(422); + }); + + it('returns 422 when fewer than 2 splits are supplied', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split( + makeReq({ + ...validBody, + splits: [{ destinationAccountId: 'a', percentage: 100 }], + }), + res as unknown as Response, + ); + expect(res.statusCode).toBe(422); + }); + + // ── 500 internal error ───────────────────────────────────────────────────── + + it('returns 500 on an unexpected internal error', async () => { + const ctrl = new PaymentSplitController({ + split: async () => { + throw new TypeError('unexpected'); + }, + } as unknown as PaymentSplitService); + const res = makeRes(); + await ctrl.split(makeReq(validBody), res as unknown as Response); + expect(res.statusCode).toBe(500); + }); + + // ── Request-ID propagation ───────────────────────────────────────────────── + + it('echoes the x-request-id header in the response meta', async () => { + const ctrl = makeController(); + const res = makeRes(); + await ctrl.split( + makeReq(validBody, { 'x-request-id': 'req-abc-123' }), + res as unknown as Response, + ); + const meta = (res.body as { meta: { requestId: string } }).meta; + expect(meta.requestId).toBe('req-abc-123'); + }); +}); diff --git a/backend/services/payment/controller/paymentSplitController.ts b/backend/services/payment/controller/paymentSplitController.ts new file mode 100644 index 00000000..a9059de5 --- /dev/null +++ b/backend/services/payment/controller/paymentSplitController.ts @@ -0,0 +1,103 @@ +/** + * PaymentSplitController – handles POST /payments/split (Issue #1720). + */ + +import type { Request, Response } from 'express'; +import { ok, fail } from '../../shared/apiResponse'; +import { + PaymentSplitService, + type SplitPaymentRequest, + type PaymentSplitEntry, +} from '../domain/PaymentSplitService'; +import { PaymentError, PaymentErrorCode } from '../errors'; +import { paymentRouter } from '../domain/PaymentRouter'; + +// ─── Singleton ──────────────────────────────────────────────────────────────── + +export const defaultSplitService = new PaymentSplitService(paymentRouter); + +// ─── Controller ────────────────────────────────────────────────────────────── + +function requestIdFrom(req: Request): string | undefined { + return (req.headers['x-request-id'] as string | undefined) ?? undefined; +} + +export class PaymentSplitController { + constructor(private readonly service: PaymentSplitService = defaultSplitService) {} + + /** + * POST /payments/split + * + * Body (JSON): + * { + * amount : number, // total amount in smallest unit + * currency : string, // e.g. "USD", "XLM" + * idempotencyKey: string, + * paymentMethodId?: string, + * chainType? : "evm" | "stellar" | "fiat", + * chainId? : number, + * metadata? : Record, + * splits: [ + * { destinationAccountId: string, percentage: number }, + * ... + * ] + * } + */ + async split(req: Request, res: Response): Promise { + const requestId = requestIdFrom(req); + const body = req.body as Partial[] }>; + + // ── Basic structural validation ────────────────────────────────────────── + if (typeof body.amount !== 'number' || body.amount <= 0) { + res.status(400).json(fail('VALIDATION_ERROR', 'amount must be a positive number', requestId)); + return; + } + + if (typeof body.currency !== 'string' || !body.currency.trim()) { + res.status(400).json(fail('VALIDATION_ERROR', 'currency is required', requestId)); + return; + } + + if (typeof body.idempotencyKey !== 'string' || !body.idempotencyKey.trim()) { + res.status(400).json(fail('VALIDATION_ERROR', 'idempotencyKey is required', requestId)); + return; + } + + if (!Array.isArray(body.splits) || body.splits.length === 0) { + res.status(400).json(fail('VALIDATION_ERROR', 'splits must be a non-empty array', requestId)); + return; + } + + // ── Delegate to domain service ─────────────────────────────────────────── + try { + const result = await this.service.split({ + amount: body.amount, + currency: body.currency.trim(), + idempotencyKey: body.idempotencyKey.trim(), + paymentMethodId: body.paymentMethodId, + chainType: body.chainType, + chainId: body.chainId, + metadata: body.metadata, + splits: body.splits as PaymentSplitEntry[], + }); + + // Return 207 Multi-Status when at least one split failed but others + // succeeded; 200 only when everything succeeded. + const statusCode = result.allSucceeded ? 200 : 207; + res.status(statusCode).json(ok(result, requestId)); + } catch (err) { + if (err instanceof PaymentError && err.code === PaymentErrorCode.SPLIT_VALIDATION_ERROR) { + res.status(422).json(fail('VALIDATION_ERROR', err.message, requestId)); + return; + } + if (err instanceof PaymentError) { + res.status(400).json(fail('PAYMENT_ERROR' as never, err.message, requestId)); + return; + } + const message = err instanceof Error ? err.message : 'Internal server error'; + res.status(500).json(fail('INTERNAL_SERVER_ERROR', message, requestId)); + } + } +} + +export const paymentSplitController = new PaymentSplitController(); diff --git a/backend/services/payment/domain/PaymentSplitService.ts b/backend/services/payment/domain/PaymentSplitService.ts new file mode 100644 index 00000000..c40b9cd4 --- /dev/null +++ b/backend/services/payment/domain/PaymentSplitService.ts @@ -0,0 +1,217 @@ +/** + * PaymentSplitService – cross-account payment splitting (Issue #1720). + * + * Splits a single payment across multiple destination accounts by percentage. + * + * Rules: + * - At least two splits are required. + * - Each split percentage must be > 0. + * - Percentages must sum exactly to 100 (within a 0.01 floating-point tolerance). + * - Each split is executed as an independent charge via the payment router. + * - Every split result is recorded in the returned SplitPaymentResult. + * - If any individual charge fails the service still attempts the remaining + * splits, then surfaces per-split errors in the response. + */ + +import { logger } from '../../shared/logging'; +import type { IPaymentRouter, PaymentRequest, PaymentResult } from '../interfaces'; +import { PaymentError, PaymentErrorCode } from '../errors'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +/** A single destination account and the percentage of the total it should receive. */ +export interface PaymentSplitEntry { + /** Destination account / customer identifier understood by the payment gateway. */ + destinationAccountId: string; + /** + * Percentage of the total amount to charge this account, expressed as a + * number between 0 (exclusive) and 100 (inclusive), e.g. `30` = 30 %. + * All percentages in a split request must sum to exactly 100. + */ + percentage: number; +} + +/** The request payload for a split payment. */ +export interface SplitPaymentRequest { + /** Total amount to distribute across all destinations, in the smallest currency unit. */ + amount: number; + currency: string; + /** Idempotency key for the *overall* split operation. Per-split keys are derived from this. */ + idempotencyKey: string; + /** Optional payment method ID forwarded to each per-split charge. */ + paymentMethodId?: string; + /** Optional chain / gateway routing hints forwarded to each charge. */ + chainType?: 'evm' | 'stellar' | 'fiat'; + chainId?: number; + metadata?: Record; + /** The splits that describe how to distribute the payment. */ + splits: PaymentSplitEntry[]; +} + +/** The result of one individual split charge. */ +export interface SplitEntryResult { + destinationAccountId: string; + percentage: number; + amount: number; + status: 'succeeded' | 'failed' | 'pending'; + chargeId?: string; + gatewayUsed?: string; + errorMessage?: string; + processedAt?: string; +} + +/** The aggregate result returned after executing all splits. */ +export interface SplitPaymentResult { + /** True only when every individual split succeeded. */ + allSucceeded: boolean; + totalAmount: number; + currency: string; + splits: SplitEntryResult[]; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const PERCENTAGE_SUM_TOLERANCE = 0.01; + +/** + * Validates that the splits list is well-formed and sums to 100 %. + * Throws a {@link PaymentError} with code SPLIT_VALIDATION_ERROR on failure. + */ +export function validateSplits(splits: PaymentSplitEntry[]): void { + if (!Array.isArray(splits) || splits.length < 2) { + throw new PaymentError( + PaymentErrorCode.SPLIT_VALIDATION_ERROR, + 'At least two split entries are required', + { required: '2', received: String(splits?.length ?? 0) }, + ); + } + + for (const [i, entry] of splits.entries()) { + if (typeof entry.destinationAccountId !== 'string' || !entry.destinationAccountId.trim()) { + throw new PaymentError( + PaymentErrorCode.SPLIT_VALIDATION_ERROR, + `Split entry at index ${i} is missing a valid destinationAccountId`, + { index: String(i) }, + ); + } + if (typeof entry.percentage !== 'number' || !Number.isFinite(entry.percentage) || entry.percentage <= 0) { + throw new PaymentError( + PaymentErrorCode.SPLIT_VALIDATION_ERROR, + `Split entry at index ${i} has an invalid percentage: must be a positive finite number`, + { index: String(i), percentage: String(entry.percentage) }, + ); + } + } + + const total = splits.reduce((sum, s) => sum + s.percentage, 0); + if (Math.abs(total - 100) > PERCENTAGE_SUM_TOLERANCE) { + throw new PaymentError( + PaymentErrorCode.SPLIT_VALIDATION_ERROR, + `Split percentages must sum to 100 but got ${total.toFixed(4)}`, + { sum: total.toFixed(4) }, + ); + } +} + +/** + * Calculates the amount for one split, rounding to 2 decimal places. + * The last split in the list receives whatever is left over to avoid + * floating-point drift accumulating across the full amount. + */ +export function splitAmount(totalAmount: number, percentage: number, _isLast: boolean, alreadyAllocated: number): number { + if (_isLast) { + const remaining = Math.round((totalAmount - alreadyAllocated) * 100) / 100; + return remaining > 0 ? remaining : 0; + } + return Math.round(totalAmount * (percentage / 100) * 100) / 100; +} + +// ─── Service ───────────────────────────────────────────────────────────────── + +export class PaymentSplitService { + constructor(private readonly router: IPaymentRouter) {} + + /** + * Executes a split payment, charging each destination account for its share. + * + * Individual gateway errors do not abort the remaining charges; each split + * result carries its own status. The caller should inspect `allSucceeded` and + * the per-entry `errorMessage` fields. + */ + async split(request: SplitPaymentRequest): Promise { + validateSplits(request.splits); + + const results: SplitEntryResult[] = []; + let allocated = 0; + + for (const [index, entry] of request.splits.entries()) { + const isLast = index === request.splits.length - 1; + const chargeAmount = splitAmount(request.amount, entry.percentage, isLast, allocated); + + if (!isLast) { + allocated = Math.round((allocated + chargeAmount) * 100) / 100; + } + + const chargeRequest: PaymentRequest = { + amount: chargeAmount, + currency: request.currency, + customerId: entry.destinationAccountId, + paymentMethodId: request.paymentMethodId ?? '', + idempotencyKey: `${request.idempotencyKey}:split:${index}`, + ...(request.chainType !== undefined && { chainType: request.chainType }), + ...(request.chainId !== undefined && { chainId: request.chainId }), + ...(request.metadata !== undefined && { metadata: request.metadata }), + }; + + let entryResult: SplitEntryResult; + + try { + const result: PaymentResult = await this.router.charge(chargeRequest); + entryResult = { + destinationAccountId: entry.destinationAccountId, + percentage: entry.percentage, + amount: chargeAmount, + status: result.status, + chargeId: result.chargeId, + gatewayUsed: result.gatewayUsed, + processedAt: result.processedAt, + }; + + logger.info('Split payment entry processed', { + destinationAccountId: entry.destinationAccountId, + percentage: entry.percentage, + amount: chargeAmount, + status: result.status, + gatewayUsed: result.gatewayUsed, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + entryResult = { + destinationAccountId: entry.destinationAccountId, + percentage: entry.percentage, + amount: chargeAmount, + status: 'failed', + errorMessage: message, + }; + + logger.warn('Split payment entry failed', { + destinationAccountId: entry.destinationAccountId, + percentage: entry.percentage, + amount: chargeAmount, + error: message, + }); + } + + results.push(entryResult); + } + + const allSucceeded = results.every((r) => r.status === 'succeeded'); + + return { + allSucceeded, + totalAmount: request.amount, + currency: request.currency, + splits: results, + }; + } +} diff --git a/backend/services/payment/errors.ts b/backend/services/payment/errors.ts index e4fcf29c..dc26616b 100644 --- a/backend/services/payment/errors.ts +++ b/backend/services/payment/errors.ts @@ -7,6 +7,7 @@ export const PaymentErrorCode = { GATEWAY_FALLBACK_FAILED: 'PAYMENT_GATEWAY_FALLBACK_FAILED' as ErrorCode, GATEWAY_CONFIG_INVALID: 'PAYMENT_GATEWAY_CONFIG_INVALID' as ErrorCode, REFUND_PARTIAL_FAILED: 'PAYMENT_REFUND_PARTIAL_FAILED' as ErrorCode, + SPLIT_VALIDATION_ERROR: 'PAYMENT_SPLIT_VALIDATION_ERROR' as ErrorCode, } as const; export class PaymentError extends DomainError { diff --git a/backend/services/payment/index.ts b/backend/services/payment/index.ts index d4944e0f..cad32b5d 100644 --- a/backend/services/payment/index.ts +++ b/backend/services/payment/index.ts @@ -42,6 +42,20 @@ export { BasePaymentGateway } from './domain/gateways/PaymentGateway'; export { GatewayConfigController, gatewayConfigController } from './controller/gatewayConfigController'; export type { IPaymentGateway, IPaymentRouter, PaymentRequest, PaymentResult, RefundRequest, RefundResult, CustomerResult, PaymentMethodResult, PayoutRequest, PayoutResult, GatewayConfig } from './interfaces'; export { PaymentError, PaymentErrorCode } from './errors'; +// ── Cross-account payment splitting (Issue #1720) ──────────────────────────── +export { PaymentSplitService, validateSplits, splitAmount } from './domain/PaymentSplitService'; +export type { + PaymentSplitEntry, + SplitPaymentRequest, + SplitEntryResult, + SplitPaymentResult, +} from './domain/PaymentSplitService'; +export { + PaymentSplitController, + paymentSplitController, + defaultSplitService, +} from './controller/paymentSplitController'; +export { createPaymentSplitRouter } from './router/paymentSplitRouter'; export { upsertFallbackChain, getFallbackChain, diff --git a/backend/services/payment/interfaces.ts b/backend/services/payment/interfaces.ts index 7396f9fa..c60def49 100644 --- a/backend/services/payment/interfaces.ts +++ b/backend/services/payment/interfaces.ts @@ -1,3 +1,13 @@ +export interface PaymentSplit { + /** Destination account / customer identifier. */ + destinationAccountId: string; + /** + * Percentage of the total amount directed to this account. + * All percentages in a request must sum to exactly 100. + */ + percentage: number; +} + export interface PaymentRequest { amount: number; currency: string; @@ -7,6 +17,12 @@ export interface PaymentRequest { chainId?: number; chainType?: 'evm' | 'stellar' | 'fiat'; metadata?: Record; + /** + * Optional cross-account split configuration (Issue #1720). + * When present the payment router distributes the amount across multiple + * destination accounts proportionally to the listed percentages. + */ + splits?: PaymentSplit[]; } export interface PaymentResult { diff --git a/backend/services/payment/router/paymentSplitRouter.ts b/backend/services/payment/router/paymentSplitRouter.ts new file mode 100644 index 00000000..9f7997c5 --- /dev/null +++ b/backend/services/payment/router/paymentSplitRouter.ts @@ -0,0 +1,39 @@ +/** + * Payment Split Router – cross-account payment splitting (Issue #1720). + * + * Routes: + * POST /payments/split – split a payment across multiple destination accounts + */ + +import { Router, type Request, type Response, type NextFunction } from 'express'; +import { + PaymentSplitController, + paymentSplitController as defaultController, +} from '../controller/paymentSplitController'; + +type AsyncHandler = (req: Request, res: Response, next: NextFunction) => void; + +function wrap(fn: (req: Request, res: Response) => Promise): AsyncHandler { + return (req, res, next) => { + fn(req, res).catch(next); + }; +} + +export function createPaymentSplitRouter( + controller: PaymentSplitController = defaultController, +): Router { + const router = Router(); + + /** + * POST /payments/split + * + * Splits a payment across multiple destination accounts by percentage. + * Percentages must sum to exactly 100. + * Returns 200 when all splits succeed, 207 when some fail. + */ + router.post('/payments/split', wrap((req, res) => controller.split(req, res))); + + return router; +} + +export default createPaymentSplitRouter;