From bdcc74d091598712c4452e255a1c48c5904a1f46 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Mon, 5 Jan 2026 22:50:59 +0200 Subject: [PATCH 001/136] Fixed the delete user procedure --- api/go.mod | 2 +- api/go.sum | 4 ++-- api/pkg/services/marketting_service.go | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/go.mod b/api/go.mod index a7a09bc2..1a9e4882 100644 --- a/api/go.mod +++ b/api/go.mod @@ -11,7 +11,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0 github.com/NdoleStudio/go-otelroundtripper v0.0.13 github.com/NdoleStudio/lemonsqueezy-go v1.2.4 - github.com/NdoleStudio/plunk-go v0.0.1 + github.com/NdoleStudio/plunk-go v0.0.2 github.com/avast/retry-go v3.0.0+incompatible github.com/carlmjohnson/requests v0.25.1 github.com/cloudevents/sdk-go/v2 v2.16.2 diff --git a/api/go.sum b/api/go.sum index f6302a56..083d8121 100644 --- a/api/go.sum +++ b/api/go.sum @@ -58,8 +58,8 @@ github.com/NdoleStudio/go-otelroundtripper v0.0.13 h1:fDgdxcNJov4LTrMhXqJnF/E3jO github.com/NdoleStudio/go-otelroundtripper v0.0.13/go.mod h1:UIUQ22ErFoBUyLuPDrVNRRKmBHBTfzQO9GF1ztqDvqo= github.com/NdoleStudio/lemonsqueezy-go v1.2.4 h1:BhWlCUH+DIPfSn4g/V7f2nFkMCQuzno9DXKZ7YDrXXA= github.com/NdoleStudio/lemonsqueezy-go v1.2.4/go.mod h1:2uZlWgn9sbNxOx3JQWLlPrDOC6NT/wmSTOgL3U/fMMw= -github.com/NdoleStudio/plunk-go v0.0.1 h1:nWPr5pcwFDvhYGZS5n3a3cKGkQvg5re9DSAiFMZCFvs= -github.com/NdoleStudio/plunk-go v0.0.1/go.mod h1:pqG3zKhpn/A2bL1K+WsWzvfTpOeSkYgXhNk5H65uEc8= +github.com/NdoleStudio/plunk-go v0.0.2 h1:afPW7MHK4Z3rsybpJBnmTmxKCLKF1M7sPI+BNGPf35A= +github.com/NdoleStudio/plunk-go v0.0.2/go.mod h1:pqG3zKhpn/A2bL1K+WsWzvfTpOeSkYgXhNk5H65uEc8= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= diff --git a/api/pkg/services/marketting_service.go b/api/pkg/services/marketting_service.go index 17eccdd6..199eb81c 100644 --- a/api/pkg/services/marketting_service.go +++ b/api/pkg/services/marketting_service.go @@ -43,17 +43,17 @@ func (service *MarketingService) DeleteContact(ctx context.Context, email string ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - response, _, err := service.plunkClient.Contacts.List(ctx, map[string]string{"limit": "1", "search": email}) + response, _, err := service.plunkClient.Contacts.List(ctx, map[string]string{"search": email}) if err != nil { return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot search for contact with email [%s]", email))) } - if len(response.Contacts) == 0 { + if len(response.Data) == 0 { ctxLogger.Info(fmt.Sprintf("no contact found with email [%s], skipping deletion", email)) return nil } - contact := response.Contacts[0] + contact := response.Data[0] if _, err = service.plunkClient.Contacts.Delete(ctx, contact.ID); err != nil { return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot delete user with ID [%s] from contacts", contact.Data[string(semconv.EnduserIDKey)]))) } From bd94f49bcf4e7e17e7281c65307f12a2996e7003 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Wed, 14 Jan 2026 01:12:20 +0200 Subject: [PATCH 002/136] Add API for fetching the payment history for a user --- api/docs/docs.go | 361 +++++++++++++++++++------ api/docs/swagger.json | 333 ++++++++++++++++++----- api/docs/swagger.yaml | 265 +++++++++++++----- api/go.mod | 2 +- api/go.sum | 4 +- api/pkg/entities/user.go | 2 +- api/pkg/handlers/user_handler.go | 61 +++++ api/pkg/responses/billing_responses.go | 4 +- api/pkg/responses/user_responses.go | 44 ++- api/pkg/services/user_service.go | 38 ++- web/models/api.ts | 136 ++++++---- web/pages/billing/index.vue | 97 +++++++ web/store/index.ts | 32 +++ 13 files changed, 1106 insertions(+), 273 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 21f3a971..60d623e7 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -151,7 +151,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Sends bulk SMS messages to multiple users from a CSV or Excel file.", + "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).", "consumes": [ "multipart/form-data" ], @@ -165,7 +165,7 @@ const docTemplate = `{ "parameters": [ { "type": "file", - "description": "The Excel or CSV file formatted according to the templates", + "description": "The Excel or CSV file containing the messages to be sent.", "name": "document", "in": "formData", "required": true @@ -710,53 +710,6 @@ const docTemplate = `{ } } }, - "/lemonsqueezy/event": { - "post": { - "description": "Publish a lemonsqueezy event to the registered listeners", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Lemonsqueezy" - ], - "summary": "Consume a lemonsqueezy event", - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, "/message-threads": { "get": { "security": [ @@ -1419,7 +1372,7 @@ const docTemplate = `{ "ApiKeyAuth": [] } ], - "description": "Add a new SMS message to be sent by the android phone", + "description": "Add a new SMS message to be sent by your Android phone", "consumes": [ "application/json" ], @@ -1429,10 +1382,10 @@ const docTemplate = `{ "tags": [ "Messages" ], - "summary": "Send a new SMS message", + "summary": "Send an SMS message", "parameters": [ { - "description": "PostSend message request payload", + "description": "Send message request payload", "name": "payload", "in": "body", "required": true, @@ -2410,6 +2363,110 @@ const docTemplate = `{ } } }, + "/users/subscription/invoices": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Subscription invoices are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get the last 10 subscription invoices.", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserInvoicesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/subscription/invoices/{subscriptionInvoiceID}": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Generates a new invoice for the given subscription with given parameters.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/pdf" + ], + "tags": [ + "Users" + ], + "summary": "Generate a subscription invoice", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/users/{userID}/api-keys": { "delete": { "security": [ @@ -2946,28 +3003,16 @@ const docTemplate = `{ "entities.Message": { "type": "object", "required": [ - "can_be_polled", "contact", "content", "created_at", - "delivered_at", "encrypted", - "expired_at", - "failed_at", - "failure_reason", "id", - "last_attempted_at", "max_send_attempts", "order_timestamp", "owner", - "received_at", - "request_id", "request_received_at", - "scheduled_at", - "scheduled_send_time", "send_attempt_count", - "send_time", - "sent_at", "sim", "status", "type", @@ -2975,10 +3020,6 @@ const docTemplate = `{ "user_id" ], "properties": { - "can_be_polled": { - "type": "boolean", - "example": false - }, "contact": { "type": "string", "example": "+18005550100" @@ -3158,12 +3199,10 @@ const docTemplate = `{ "type": "object", "required": [ "created_at", - "fcm_token", "id", "max_send_attempts", "message_expiration_seconds", "messages_per_minute", - "missed_call_auto_reply", "phone_number", "sim", "updated_at", @@ -3284,7 +3323,6 @@ const docTemplate = `{ "entities.User": { "type": "object", "required": [ - "active_phone_id", "api_key", "created_at", "email", @@ -3293,11 +3331,7 @@ const docTemplate = `{ "notification_message_status_enabled", "notification_newsletter_enabled", "notification_webhook_enabled", - "subscription_ends_at", - "subscription_id", "subscription_name", - "subscription_renews_at", - "subscription_status", "timezone", "updated_at" ], @@ -3342,10 +3376,6 @@ const docTemplate = `{ "type": "string", "example": "2022-06-05T14:26:02.302718+03:00" }, - "subscription_id": { - "type": "string", - "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" - }, "subscription_name": { "type": "string", "example": "free" @@ -3639,9 +3669,9 @@ const docTemplate = `{ "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" }, "send_at": { - "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone.", + "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.", "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" + "example": "2025-12-19T16:39:57-08:00" }, "to": { "type": "string", @@ -4288,6 +4318,27 @@ const docTemplate = `{ } } }, + "responses.UserInvoicesResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/responses.subscriptionInvoicesAPIResponse" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.UserResponse": { "type": "object", "required": [ @@ -4353,6 +4404,154 @@ const docTemplate = `{ "example": "success" } } + }, + "responses.subscriptionInvoicesAPIResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "attributes", + "id", + "type" + ], + "properties": { + "attributes": { + "type": "object", + "required": [ + "billing_reason", + "card_brand", + "card_last_four", + "created_at", + "currency", + "currency_rate", + "discount_total", + "discount_total_formatted", + "discount_total_usd", + "refunded", + "refunded_amount", + "refunded_amount_formatted", + "refunded_amount_usd", + "refunded_at", + "status", + "status_formatted", + "subtotal", + "subtotal_formatted", + "subtotal_usd", + "tax", + "tax_formatted", + "tax_inclusive", + "tax_usd", + "total", + "total_formatted", + "total_usd", + "updated_at", + "user_email", + "user_name" + ], + "properties": { + "billing_reason": { + "type": "string" + }, + "card_brand": { + "type": "string" + }, + "card_last_four": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "currency_rate": { + "type": "string" + }, + "discount_total": { + "type": "integer" + }, + "discount_total_formatted": { + "type": "string" + }, + "discount_total_usd": { + "type": "integer" + }, + "refunded": { + "type": "boolean" + }, + "refunded_amount": { + "type": "integer" + }, + "refunded_amount_formatted": { + "type": "string" + }, + "refunded_amount_usd": { + "type": "integer" + }, + "refunded_at": {}, + "status": { + "type": "string" + }, + "status_formatted": { + "type": "string" + }, + "subtotal": { + "type": "integer" + }, + "subtotal_formatted": { + "type": "string" + }, + "subtotal_usd": { + "type": "integer" + }, + "tax": { + "type": "integer" + }, + "tax_formatted": { + "type": "string" + }, + "tax_inclusive": { + "type": "boolean" + }, + "tax_usd": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_formatted": { + "type": "string" + }, + "total_usd": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_email": { + "type": "string" + }, + "user_name": { + "type": "string" + } + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } + } } }, "securityDefinitions": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 5cef4eb0..35e5a245 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -133,7 +133,7 @@ "ApiKeyAuth": [] } ], - "description": "Sends bulk SMS messages to multiple users from a CSV or Excel file.", + "description": "Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx).", "consumes": ["multipart/form-data"], "produces": ["application/json"], "tags": ["BulkSMS"], @@ -141,7 +141,7 @@ "parameters": [ { "type": "file", - "description": "The Excel or CSV file formatted according to the templates", + "description": "The Excel or CSV file containing the messages to be sent.", "name": "document", "in": "formData", "required": true @@ -638,47 +638,6 @@ } } }, - "/lemonsqueezy/event": { - "post": { - "description": "Publish a lemonsqueezy event to the registered listeners", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": ["Lemonsqueezy"], - "summary": "Consume a lemonsqueezy event", - "responses": { - "204": { - "description": "No Content", - "schema": { - "$ref": "#/definitions/responses.NoContent" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/responses.BadRequest" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/responses.Unauthorized" - } - }, - "422": { - "description": "Unprocessable Entity", - "schema": { - "$ref": "#/definitions/responses.UnprocessableEntity" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/responses.InternalServerError" - } - } - } - } - }, "/message-threads": { "get": { "security": [ @@ -1287,14 +1246,14 @@ "ApiKeyAuth": [] } ], - "description": "Add a new SMS message to be sent by the android phone", + "description": "Add a new SMS message to be sent by your Android phone", "consumes": ["application/json"], "produces": ["application/json"], "tags": ["Messages"], - "summary": "Send a new SMS message", + "summary": "Send an SMS message", "parameters": [ { - "description": "PostSend message request payload", + "description": "Send message request payload", "name": "payload", "in": "body", "required": true, @@ -2186,6 +2145,98 @@ } } }, + "/users/subscription/invoices": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Subscription invoices are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get the last 10 subscription invoices.", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/responses.UserInvoicesResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, + "/users/subscription/invoices/{subscriptionInvoiceID}": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Generates a new invoice for the given subscription with given parameters.", + "consumes": ["application/json"], + "produces": ["application/pdf"], + "tags": ["Users"], + "summary": "Generate a subscription invoice", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/responses.BadRequest" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/responses.Unauthorized" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/responses.UnprocessableEntity" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/responses.InternalServerError" + } + } + } + } + }, "/users/{userID}/api-keys": { "delete": { "security": [ @@ -2686,28 +2737,16 @@ "entities.Message": { "type": "object", "required": [ - "can_be_polled", "contact", "content", "created_at", - "delivered_at", "encrypted", - "expired_at", - "failed_at", - "failure_reason", "id", - "last_attempted_at", "max_send_attempts", "order_timestamp", "owner", - "received_at", - "request_id", "request_received_at", - "scheduled_at", - "scheduled_send_time", "send_attempt_count", - "send_time", - "sent_at", "sim", "status", "type", @@ -2715,10 +2754,6 @@ "user_id" ], "properties": { - "can_be_polled": { - "type": "boolean", - "example": false - }, "contact": { "type": "string", "example": "+18005550100" @@ -2898,12 +2933,10 @@ "type": "object", "required": [ "created_at", - "fcm_token", "id", "max_send_attempts", "message_expiration_seconds", "messages_per_minute", - "missed_call_auto_reply", "phone_number", "sim", "updated_at", @@ -3021,7 +3054,6 @@ "entities.User": { "type": "object", "required": [ - "active_phone_id", "api_key", "created_at", "email", @@ -3030,11 +3062,7 @@ "notification_message_status_enabled", "notification_newsletter_enabled", "notification_webhook_enabled", - "subscription_ends_at", - "subscription_id", "subscription_name", - "subscription_renews_at", - "subscription_status", "timezone", "updated_at" ], @@ -3079,10 +3107,6 @@ "type": "string", "example": "2022-06-05T14:26:02.302718+03:00" }, - "subscription_id": { - "type": "string", - "example": "8f9c71b8-b84e-4417-8408-a62274f65a08" - }, "subscription_name": { "type": "string", "example": "free" @@ -3332,9 +3356,9 @@ "example": "153554b5-ae44-44a0-8f4f-7bbac5657ad4" }, "send_at": { - "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone.", + "description": "SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future.", "type": "string", - "example": "2022-06-05T14:26:09.527976+03:00" + "example": "2025-12-19T16:39:57-08:00" }, "to": { "type": "string", @@ -3877,6 +3901,23 @@ } } }, + "responses.UserInvoicesResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/responses.subscriptionInvoicesAPIResponse" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, "responses.UserResponse": { "type": "object", "required": ["data", "message", "status"], @@ -3930,6 +3971,148 @@ "example": "success" } } + }, + "responses.subscriptionInvoicesAPIResponse": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["attributes", "id", "type"], + "properties": { + "attributes": { + "type": "object", + "required": [ + "billing_reason", + "card_brand", + "card_last_four", + "created_at", + "currency", + "currency_rate", + "discount_total", + "discount_total_formatted", + "discount_total_usd", + "refunded", + "refunded_amount", + "refunded_amount_formatted", + "refunded_amount_usd", + "refunded_at", + "status", + "status_formatted", + "subtotal", + "subtotal_formatted", + "subtotal_usd", + "tax", + "tax_formatted", + "tax_inclusive", + "tax_usd", + "total", + "total_formatted", + "total_usd", + "updated_at", + "user_email", + "user_name" + ], + "properties": { + "billing_reason": { + "type": "string" + }, + "card_brand": { + "type": "string" + }, + "card_last_four": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "currency_rate": { + "type": "string" + }, + "discount_total": { + "type": "integer" + }, + "discount_total_formatted": { + "type": "string" + }, + "discount_total_usd": { + "type": "integer" + }, + "refunded": { + "type": "boolean" + }, + "refunded_amount": { + "type": "integer" + }, + "refunded_amount_formatted": { + "type": "string" + }, + "refunded_amount_usd": { + "type": "integer" + }, + "refunded_at": {}, + "status": { + "type": "string" + }, + "status_formatted": { + "type": "string" + }, + "subtotal": { + "type": "integer" + }, + "subtotal_formatted": { + "type": "string" + }, + "subtotal_usd": { + "type": "integer" + }, + "tax": { + "type": "integer" + }, + "tax_formatted": { + "type": "string" + }, + "tax_inclusive": { + "type": "boolean" + }, + "tax_usd": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_formatted": { + "type": "string" + }, + "total_usd": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "user_email": { + "type": "string" + }, + "user_name": { + "type": "string" + } + } + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } + } } }, "securityDefinitions": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index efc316c4..fdde688c 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -102,9 +102,6 @@ definitions: type: object entities.Message: properties: - can_be_polled: - example: false - type: boolean contact: example: "+18005550100" type: string @@ -192,28 +189,16 @@ definitions: example: WB7DRDWrJZRGbYrv2CKGkqbzvqdC type: string required: - - can_be_polled - contact - content - created_at - - delivered_at - encrypted - - expired_at - - failed_at - - failure_reason - id - - last_attempted_at - max_send_attempts - order_timestamp - owner - - received_at - - request_id - request_received_at - - scheduled_at - - scheduled_send_time - send_attempt_count - - send_time - - sent_at - sim - status - type @@ -314,12 +299,10 @@ definitions: type: string required: - created_at - - fcm_token - id - max_send_attempts - message_expiration_seconds - messages_per_minute - - missed_call_auto_reply - phone_number - sim - updated_at @@ -405,9 +388,6 @@ definitions: subscription_ends_at: example: "2022-06-05T14:26:02.302718+03:00" type: string - subscription_id: - example: 8f9c71b8-b84e-4417-8408-a62274f65a08 - type: string subscription_name: example: free type: string @@ -424,7 +404,6 @@ definitions: example: "2022-06-05T14:26:10.303278+03:00" type: string required: - - active_phone_id - api_key - created_at - email @@ -433,11 +412,7 @@ definitions: - notification_message_status_enabled - notification_newsletter_enabled - notification_webhook_enabled - - subscription_ends_at - - subscription_id - subscription_name - - subscription_renews_at - - subscription_status - timezone - updated_at type: object @@ -659,8 +634,9 @@ definitions: description: SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local - timezone. - example: "2022-06-05T14:26:09.527976+03:00" + timezone and you can queue messages for up to 20 days (480 hours) in the + future. + example: "2025-12-19T16:39:57-08:00" type: string to: example: "+18005550100" @@ -1135,6 +1111,21 @@ definitions: - message - status type: object + responses.UserInvoicesResponse: + properties: + data: + $ref: "#/definitions/responses.subscriptionInvoicesAPIResponse" + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object responses.UserResponse: properties: data: @@ -1182,6 +1173,114 @@ definitions: - message - status type: object + responses.subscriptionInvoicesAPIResponse: + properties: + data: + items: + properties: + attributes: + properties: + billing_reason: + type: string + card_brand: + type: string + card_last_four: + type: string + created_at: + type: string + currency: + type: string + currency_rate: + type: string + discount_total: + type: integer + discount_total_formatted: + type: string + discount_total_usd: + type: integer + refunded: + type: boolean + refunded_amount: + type: integer + refunded_amount_formatted: + type: string + refunded_amount_usd: + type: integer + refunded_at: {} + status: + type: string + status_formatted: + type: string + subtotal: + type: integer + subtotal_formatted: + type: string + subtotal_usd: + type: integer + tax: + type: integer + tax_formatted: + type: string + tax_inclusive: + type: boolean + tax_usd: + type: integer + total: + type: integer + total_formatted: + type: string + total_usd: + type: integer + updated_at: + type: string + user_email: + type: string + user_name: + type: string + required: + - billing_reason + - card_brand + - card_last_four + - created_at + - currency + - currency_rate + - discount_total + - discount_total_formatted + - discount_total_usd + - refunded + - refunded_amount + - refunded_amount_formatted + - refunded_amount_usd + - refunded_at + - status + - status_formatted + - subtotal + - subtotal_formatted + - subtotal_usd + - tax + - tax_formatted + - tax_inclusive + - tax_usd + - total + - total_formatted + - total_usd + - updated_at + - user_email + - user_name + type: object + id: + type: string + type: + type: string + required: + - attributes + - id + - type + type: object + type: array + required: + - data + type: object host: api.httpsms.com info: contact: @@ -1282,9 +1381,11 @@ paths: post: consumes: - multipart/form-data - description: Sends bulk SMS messages to multiple users from a CSV or Excel file. + description: + Sends bulk SMS messages to multiple users based on our [CSV template](https://httpsms.com/templates/httpsms-bulk.csv) + or our [Excel template](https://httpsms.com/templates/httpsms-bulk.xlsx). parameters: - - description: The Excel or CSV file formatted according to the templates + - description: The Excel or CSV file containing the messages to be sent. in: formData name: document required: true @@ -1650,37 +1751,6 @@ paths: summary: Sends a 3CX SMS message tags: - 3CXIntegration - /lemonsqueezy/event: - post: - consumes: - - application/json - description: Publish a lemonsqueezy event to the registered listeners - produces: - - application/json - responses: - "204": - description: No Content - schema: - $ref: "#/definitions/responses.NoContent" - "400": - description: Bad Request - schema: - $ref: "#/definitions/responses.BadRequest" - "401": - description: Unauthorized - schema: - $ref: "#/definitions/responses.Unauthorized" - "422": - description: Unprocessable Entity - schema: - $ref: "#/definitions/responses.UnprocessableEntity" - "500": - description: Internal Server Error - schema: - $ref: "#/definitions/responses.InternalServerError" - summary: Consume a lemonsqueezy event - tags: - - Lemonsqueezy /message-threads: get: consumes: @@ -2220,9 +2290,9 @@ paths: post: consumes: - application/json - description: Add a new SMS message to be sent by the android phone + description: Add a new SMS message to be sent by your Android phone parameters: - - description: PostSend message request payload + - description: Send message request payload in: body name: payload required: true @@ -2253,7 +2323,7 @@ paths: $ref: "#/definitions/responses.InternalServerError" security: - ApiKeyAuth: [] - summary: Send a new SMS message + summary: Send an SMS message tags: - Messages /phone-api-keys: @@ -2866,6 +2936,75 @@ paths: summary: Currently authenticated user subscription update URL tags: - Users + /users/subscription/invoices: + get: + consumes: + - application/json + description: + Subscription invoices are generated throughout the lifecycle of + a subscription, typically there is one at the time of purchase and then one + for each renewal. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: "#/definitions/responses.UserInvoicesResponse" + "400": + description: Bad Request + schema: + $ref: "#/definitions/responses.BadRequest" + "401": + description: Unauthorized + schema: + $ref: "#/definitions/responses.Unauthorized" + "422": + description: Unprocessable Entity + schema: + $ref: "#/definitions/responses.UnprocessableEntity" + "500": + description: Internal Server Error + schema: + $ref: "#/definitions/responses.InternalServerError" + security: + - ApiKeyAuth: [] + summary: Get the last 10 subscription invoices. + tags: + - Users + /users/subscription/invoices/{subscriptionInvoiceID}: + post: + consumes: + - application/json + description: Generates a new invoice for the given subscription with given parameters. + produces: + - application/pdf + responses: + "200": + description: OK + schema: + type: file + "400": + description: Bad Request + schema: + $ref: "#/definitions/responses.BadRequest" + "401": + description: Unauthorized + schema: + $ref: "#/definitions/responses.Unauthorized" + "422": + description: Unprocessable Entity + schema: + $ref: "#/definitions/responses.UnprocessableEntity" + "500": + description: Internal Server Error + schema: + $ref: "#/definitions/responses.InternalServerError" + security: + - ApiKeyAuth: [] + summary: Generate a subscription invoice + tags: + - Users /webhooks: get: consumes: diff --git a/api/go.mod b/api/go.mod index 1a9e4882..a798964a 100644 --- a/api/go.mod +++ b/api/go.mod @@ -10,7 +10,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.30.0 github.com/NdoleStudio/go-otelroundtripper v0.0.13 - github.com/NdoleStudio/lemonsqueezy-go v1.2.4 + github.com/NdoleStudio/lemonsqueezy-go v1.3.1 github.com/NdoleStudio/plunk-go v0.0.2 github.com/avast/retry-go v3.0.0+incompatible github.com/carlmjohnson/requests v0.25.1 diff --git a/api/go.sum b/api/go.sum index 083d8121..d1685d6c 100644 --- a/api/go.sum +++ b/api/go.sum @@ -56,8 +56,8 @@ github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZC github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/NdoleStudio/go-otelroundtripper v0.0.13 h1:fDgdxcNJov4LTrMhXqJnF/E3jO4HJVczj90wkxh5PSc= github.com/NdoleStudio/go-otelroundtripper v0.0.13/go.mod h1:UIUQ22ErFoBUyLuPDrVNRRKmBHBTfzQO9GF1ztqDvqo= -github.com/NdoleStudio/lemonsqueezy-go v1.2.4 h1:BhWlCUH+DIPfSn4g/V7f2nFkMCQuzno9DXKZ7YDrXXA= -github.com/NdoleStudio/lemonsqueezy-go v1.2.4/go.mod h1:2uZlWgn9sbNxOx3JQWLlPrDOC6NT/wmSTOgL3U/fMMw= +github.com/NdoleStudio/lemonsqueezy-go v1.3.1 h1:lMUVgdAx2onbOUJIVPR05xAANYuCMXBRaGWpAdA4LiM= +github.com/NdoleStudio/lemonsqueezy-go v1.3.1/go.mod h1:xKRsRX1jSI6mLrVXyWh2sF/1isxTioZrSjWy6HpA3xQ= github.com/NdoleStudio/plunk-go v0.0.2 h1:afPW7MHK4Z3rsybpJBnmTmxKCLKF1M7sPI+BNGPf35A= github.com/NdoleStudio/plunk-go v0.0.2/go.mod h1:pqG3zKhpn/A2bL1K+WsWzvfTpOeSkYgXhNk5H65uEc8= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= diff --git a/api/pkg/entities/user.go b/api/pkg/entities/user.go index a6a92371..fd8de1a5 100644 --- a/api/pkg/entities/user.go +++ b/api/pkg/entities/user.go @@ -78,7 +78,7 @@ type User struct { Timezone string `json:"timezone" example:"Europe/Helsinki" gorm:"default:Africa/Accra"` ActivePhoneID *uuid.UUID `json:"active_phone_id" gorm:"type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb" validate:"optional"` SubscriptionName SubscriptionName `json:"subscription_name" example:"free"` - SubscriptionID *string `json:"-" example:"8f9c71b8-b84e-4417-8408-a62274f65a08" swaggerignore:"true"` + SubscriptionID *string `json:"subscription_id" example:"8f9c71b8-b84e-4417-8408-a62274f65a08" swaggerignore:"true"` SubscriptionStatus *string `json:"subscription_status" example:"on_trial" validate:"optional"` SubscriptionRenewsAt *time.Time `json:"subscription_renews_at" example:"2022-06-05T14:26:02.302718+03:00" validate:"optional"` SubscriptionEndsAt *time.Time `json:"subscription_ends_at" example:"2022-06-05T14:26:02.302718+03:00" validate:"optional"` diff --git a/api/pkg/handlers/user_handler.go b/api/pkg/handlers/user_handler.go index 44ec619e..574edf27 100644 --- a/api/pkg/handlers/user_handler.go +++ b/api/pkg/handlers/user_handler.go @@ -46,6 +46,8 @@ func (h *UserHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.H router.Put("/v1/users/:userID/notifications", h.computeRoute(middlewares, h.UpdateNotifications)...) router.Get("/v1/users/subscription-update-url", h.computeRoute(middlewares, h.subscriptionUpdateURL)...) router.Delete("/v1/users/subscription", h.computeRoute(middlewares, h.cancelSubscription)...) + router.Get("/v1/users/subscription/invoices", h.computeRoute(middlewares, h.subscriptionPayments)...) + router.Post("/v1/users/subscription/invoices/:subscriptionInvoiceID", h.computeRoute(middlewares, h.subscriptionInvoice)...) } // Show returns an entities.User @@ -272,3 +274,62 @@ func (h *UserHandler) DeleteAPIKey(c *fiber.Ctx) error { return h.responseOK(c, "API Key rotated successfully", user) } + +// subscriptionPayments returns the last 10 payments of the currently authenticated user +// @Summary Get the last 10 subscription payments. +// @Description Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal. +// @Security ApiKeyAuth +// @Tags Users +// @Accept json +// @Produce json +// @Success 200 {object} responses.UserSubscriptionPaymentsResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /users/subscription/payments [get] +func (h *UserHandler) subscriptionPayments(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + invoices, err := h.service.GetSubscriptionPayments(ctx, h.userIDFomContext(c)) + if err != nil { + msg := fmt.Sprintf("cannot get current subscription invoices for user [%s]", h.userFromContext(c)) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "fetched subscription invoices billing usage", invoices) +} + +// subscriptionInvoice generates an invoice for a given subscription invoice ID +// @Summary Generate a subscription invoice +// @Description Generates a new invoice PDF file for the given subscription payment with given parameters. +// @Security ApiKeyAuth +// @Tags Users +// @Accept json +// @Produce application/pdf +// @Success 200 {file} file +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /users/subscription/invoices/{subscriptionInvoiceID} [post] +func (h *UserHandler) subscriptionInvoice(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + invoiceID := c.Params("subscriptionInvoiceID") + + data, err := h.service.GenerateReceipt(ctx, h.userIDFomContext(c)) + if err != nil { + msg := fmt.Sprintf("cannot generate receipt for invoice ID [%s] and user [%s]", invoiceID, h.userFromContext(c)) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + c.Set(fiber.HeaderContentType, "application/pdf") + c.Set(fiber.HeaderContentDisposition, fmt.Sprintf("attachment; filename=\"%s.pdf\"", invoiceID)) + + return c.SendStream(data) +} diff --git a/api/pkg/responses/billing_responses.go b/api/pkg/responses/billing_responses.go index bb51d6ab..0ce46415 100644 --- a/api/pkg/responses/billing_responses.go +++ b/api/pkg/responses/billing_responses.go @@ -1,6 +1,8 @@ package responses -import "github.com/NdoleStudio/httpsms/pkg/entities" +import ( + "github.com/NdoleStudio/httpsms/pkg/entities" +) // BillingUsagesResponse is the payload containing []entities.BillingUsage type BillingUsagesResponse struct { diff --git a/api/pkg/responses/user_responses.go b/api/pkg/responses/user_responses.go index f2ee6c37..31f95341 100644 --- a/api/pkg/responses/user_responses.go +++ b/api/pkg/responses/user_responses.go @@ -1,9 +1,51 @@ package responses -import "github.com/NdoleStudio/httpsms/pkg/entities" +import ( + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" +) // UserResponse is the payload containing entities.User type UserResponse struct { response Data entities.User `json:"data"` } + +// UserSubscriptionPaymentsResponse is the payload containing lemonsqueezy.SubscriptionInvoicesAPIResponse +type UserSubscriptionPaymentsResponse struct { + response + Data []struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes struct { + BillingReason string `json:"billing_reason"` + CardBrand string `json:"card_brand"` + CardLastFour string `json:"card_last_four"` + Currency string `json:"currency"` + CurrencyRate string `json:"currency_rate"` + Status string `json:"status"` + StatusFormatted string `json:"status_formatted"` + Refunded bool `json:"refunded"` + RefundedAt any `json:"refunded_at"` + Subtotal int `json:"subtotal"` + DiscountTotal int `json:"discount_total"` + Tax int `json:"tax"` + TaxInclusive bool `json:"tax_inclusive"` + Total int `json:"total"` + RefundedAmount int `json:"refunded_amount"` + SubtotalUsd int `json:"subtotal_usd"` + DiscountTotalUsd int `json:"discount_total_usd"` + TaxUsd int `json:"tax_usd"` + TotalUsd int `json:"total_usd"` + RefundedAmountUsd int `json:"refunded_amount_usd"` + SubtotalFormatted string `json:"subtotal_formatted"` + DiscountTotalFormatted string `json:"discount_total_formatted"` + TaxFormatted string `json:"tax_formatted"` + TotalFormatted string `json:"total_formatted"` + RefundedAmountFormatted string `json:"refunded_amount_formatted"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } `json:"attributes"` + } `json:"data"` +} diff --git a/api/pkg/services/user_service.go b/api/pkg/services/user_service.go index e030e1f8..860e1a42 100644 --- a/api/pkg/services/user_service.go +++ b/api/pkg/services/user_service.go @@ -3,10 +3,10 @@ package services import ( "context" "fmt" + "io" "time" "firebase.google.com/go/auth" - "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/httpsms/pkg/emails" @@ -56,6 +56,42 @@ func NewUserService( } } +// GetSubscriptionPayments fetches the subscription payments for an entities.User +func (service *UserService) GetSubscriptionPayments(ctx context.Context, userID entities.UserID) (invoices []lemonsqueezy.ApiResponseData[lemonsqueezy.SubscriptionInvoiceAttributes, lemonsqueezy.APIResponseRelationshipsSubscriptionInvoice], err error) { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + user, err := service.repository.Load(ctx, userID) + if err != nil { + msg := fmt.Sprintf("could not get [%T] with with ID [%s]", user, userID) + return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if user.SubscriptionID == nil { + ctxLogger.Info(fmt.Sprintf("no subscription ID found for [%T] with ID [%s], returning empty invoices", user, user.ID)) + return invoices, nil + } + + ctxLogger.Info(fmt.Sprintf("fetching subscription payments for [%T] with ID [%s] and subscription [%s]", user, user.ID, *user.SubscriptionID)) + invoicesResponse, _, err := service.lemonsqueezyClient.SubscriptionInvoices.List(ctx, map[string]string{"filter[subscription_id]": *user.SubscriptionID}) + if err != nil { + msg := fmt.Sprintf("could not get invoices for subscription [%s] for [%T] with with ID [%s]", *user.SubscriptionID, user, user.ID) + return invoices, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + ctxLogger.Info(fmt.Sprintf("fetched [%d] payments for [%T] with ID [%s] and subscription ID [%s]", len(invoicesResponse.Data), user, user.ID, *user.SubscriptionID)) + return invoicesResponse.Data, nil +} + +// GenerateReceipt generates a receipt for a subscription payment. +func (service *UserService) GenerateReceipt(ctx context.Context, userID entities.UserID) (reader io.Reader, err error) { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + ctxLogger.Info(fmt.Sprintf("generating receipt for user [%s]", userID)) + return nil, nil +} + // Get fetches or creates an entities.User func (service *UserService) Get(ctx context.Context, source string, authUser entities.AuthContext) (*entities.User, error) { ctx, span := service.tracer.Start(ctx) diff --git a/web/models/api.ts b/web/models/api.ts index 7b2d542f..99f15064 100644 --- a/web/models/api.ts +++ b/web/models/api.ts @@ -64,8 +64,6 @@ export interface EntitiesHeartbeat { } export interface EntitiesMessage { - /** @example false */ - can_be_polled: boolean /** @example "+18005550100" */ contact: string /** @example "This is a sample text message" */ @@ -73,19 +71,19 @@ export interface EntitiesMessage { /** @example "2022-06-05T14:26:02.302718+03:00" */ created_at: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - delivered_at: string + delivered_at?: string /** @example false */ encrypted: boolean /** @example "2022-06-05T14:26:09.527976+03:00" */ - expired_at: string + expired_at?: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - failed_at: string + failed_at?: string /** @example "UNKNOWN" */ - failure_reason: string + failure_reason?: string /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ id: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - last_attempted_at: string + last_attempted_at?: string /** @example 1 */ max_send_attempts: number /** @example "2022-06-05T14:26:09.527976+03:00" */ @@ -93,24 +91,24 @@ export interface EntitiesMessage { /** @example "+18005550199" */ owner: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - received_at: string + received_at?: string /** @example "153554b5-ae44-44a0-8f4f-7bbac5657ad4" */ - request_id: string + request_id?: string /** @example "2022-06-05T14:26:01.520828+03:00" */ request_received_at: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - scheduled_at: string + scheduled_at?: string /** @example "2022-06-05T14:26:09.527976+03:00" */ - scheduled_send_time: string + scheduled_send_time?: string /** @example 0 */ send_attempt_count: number /** * SendDuration is the number of nanoseconds from when the request was received until when the mobile phone send the message * @example 133414 */ - send_time: number + send_time?: number /** @example "2022-06-05T14:26:09.527976+03:00" */ - sent_at: string + sent_at?: string /** * SIM is the SIM card to use to send the message * * SMS1: use the SIM card in slot 1 @@ -160,7 +158,7 @@ export interface EntitiesPhone { /** @example "2022-06-05T14:26:02.302718+03:00" */ created_at: string /** @example "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....." */ - fcm_token: string + fcm_token?: string /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ id: string /** @@ -173,7 +171,7 @@ export interface EntitiesPhone { /** @example 1 */ messages_per_minute: number /** @example "This phone cannot receive calls. Please send an SMS instead." */ - missed_call_auto_reply: string + missed_call_auto_reply?: string /** @example "+18005550199" */ phone_number: string /** SIM card that received the message */ @@ -185,7 +183,7 @@ export interface EntitiesPhone { } export interface EntitiesPhoneAPIKey { - /** @example "pk_DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" */ + /** @example "pk_DGW8NwQp7mxKaSZ72Xq9v6xxxxx" */ api_key: string /** @example "2022-06-05T14:26:02.302718+03:00" */ created_at: string @@ -193,9 +191,9 @@ export interface EntitiesPhoneAPIKey { id: string /** @example "Business Phone Key" */ name: string - /** @example ["[32343a19-da5e-4b1b-a767-3298a73703cb","32343a19-da5e-4b1b-a767-3298a73703cc]"] */ + /** @example ["32343a19-da5e-4b1b-a767-3298a73703cb","32343a19-da5e-4b1b-a767-3298a73703cc"] */ phone_ids: string[] - /** @example ["[+18005550199","+18005550100]"] */ + /** @example ["+18005550199","+18005550100"] */ phone_numbers: string[] /** @example "2022-06-05T14:26:02.302718+03:00" */ updated_at: string @@ -207,7 +205,7 @@ export interface EntitiesPhoneAPIKey { export interface EntitiesUser { /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ - active_phone_id: string + active_phone_id?: string /** @example "x-api-key" */ api_key: string /** @example "2022-06-05T14:26:02.302718+03:00" */ @@ -225,15 +223,13 @@ export interface EntitiesUser { /** @example true */ notification_webhook_enabled: boolean /** @example "2022-06-05T14:26:02.302718+03:00" */ - subscription_ends_at: string - /** @example "8f9c71b8-b84e-4417-8408-a62274f65a08" */ - subscription_id: string + subscription_ends_at?: string /** @example "free" */ subscription_name: string /** @example "2022-06-05T14:26:02.302718+03:00" */ - subscription_renews_at: string + subscription_renews_at?: string /** @example "on_trial" */ - subscription_status: string + subscription_status?: string /** @example "Europe/Helsinki" */ timezone: string /** @example "2022-06-05T14:26:10.303278+03:00" */ @@ -243,11 +239,11 @@ export interface EntitiesUser { export interface EntitiesWebhook { /** @example "2022-06-05T14:26:02.302718+03:00" */ created_at: string - /** @example ["[message.phone.received]"] */ + /** @example ["message.phone.received"] */ events: string[] /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ id: string - /** @example ["[+18005550199","+18005550100]"] */ + /** @example ["+18005550199","+18005550100"] */ phone_numbers: string[] /** @example "DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY" */ signing_key: string @@ -352,10 +348,10 @@ export interface RequestsMessageSend { /** @example "This is a sample text message" */ content: string /** - * Encrypted is used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app + * Encrypted is an optional parameter used to determine if the content is end-to-end encrypted. Make sure to set the encryption key on the httpSMS mobile app * @example false */ - encrypted: boolean + encrypted?: boolean /** @example "+18005550199" */ from: string /** @@ -364,8 +360,8 @@ export interface RequestsMessageSend { */ request_id?: string /** - * SendAt is an optional parameter used to schedule a message to be sent at a later time - * @example "2022-06-05T14:26:09.527976+03:00" + * SendAt is an optional parameter used to schedule a message to be sent in the future. The time is considered to be in your profile's local timezone and you can queue messages for up to 20 days (480 hours) in the future. + * @example "2025-12-19T16:39:57-08:00" */ send_at?: string /** @example "+18005550100" */ @@ -465,7 +461,7 @@ export interface ResponsesBadRequest { export interface ResponsesBillingUsageResponse { data: EntitiesBillingUsage - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -473,7 +469,7 @@ export interface ResponsesBillingUsageResponse { export interface ResponsesBillingUsagesResponse { data: EntitiesBillingUsage[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -481,7 +477,7 @@ export interface ResponsesBillingUsagesResponse { export interface ResponsesDiscordResponse { data: EntitiesDiscord - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -489,7 +485,7 @@ export interface ResponsesDiscordResponse { export interface ResponsesDiscordsResponse { data: EntitiesDiscord[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -497,7 +493,7 @@ export interface ResponsesDiscordsResponse { export interface ResponsesHeartbeatResponse { data: EntitiesHeartbeat - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -505,7 +501,7 @@ export interface ResponsesHeartbeatResponse { export interface ResponsesHeartbeatsResponse { data: EntitiesHeartbeat[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -520,7 +516,7 @@ export interface ResponsesInternalServerError { export interface ResponsesMessageResponse { data: EntitiesMessage - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -528,7 +524,7 @@ export interface ResponsesMessageResponse { export interface ResponsesMessageThreadsResponse { data: EntitiesMessageThread[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -536,7 +532,7 @@ export interface ResponsesMessageThreadsResponse { export interface ResponsesMessagesResponse { data: EntitiesMessage[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -566,7 +562,7 @@ export interface ResponsesOkString { export interface ResponsesPhoneAPIKeyResponse { data: EntitiesPhoneAPIKey - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -574,7 +570,7 @@ export interface ResponsesPhoneAPIKeyResponse { export interface ResponsesPhoneAPIKeysResponse { data: EntitiesPhoneAPIKey[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -582,7 +578,7 @@ export interface ResponsesPhoneAPIKeysResponse { export interface ResponsesPhoneResponse { data: EntitiesPhone - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -590,7 +586,7 @@ export interface ResponsesPhoneResponse { export interface ResponsesPhonesResponse { data: EntitiesPhone[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -607,15 +603,23 @@ export interface ResponsesUnauthorized { export interface ResponsesUnprocessableEntity { data: Record - /** @example "validation errors while sending message" */ + /** @example "validation errors while handling request" */ message: string /** @example "error" */ status: string } +export interface ResponsesUserInvoicesResponse { + data: ResponsesSubscriptionInvoicesAPIResponse + /** @example "Request handled successfully" */ + message: string + /** @example "success" */ + status: string +} + export interface ResponsesUserResponse { data: EntitiesUser - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -623,7 +627,7 @@ export interface ResponsesUserResponse { export interface ResponsesWebhookResponse { data: EntitiesWebhook - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string @@ -631,8 +635,46 @@ export interface ResponsesWebhookResponse { export interface ResponsesWebhooksResponse { data: EntitiesWebhook[] - /** @example "item created successfully" */ + /** @example "Request handled successfully" */ message: string /** @example "success" */ status: string } + +export interface ResponsesSubscriptionInvoicesAPIResponse { + data: { + attributes: { + billing_reason: string + card_brand: string + card_last_four: string + created_at: string + currency: string + currency_rate: string + discount_total: number + discount_total_formatted: string + discount_total_usd: number + refunded: boolean + refunded_amount: number + refunded_amount_formatted: string + refunded_amount_usd: number + refunded_at: any + status: string + status_formatted: string + subtotal: number + subtotal_formatted: string + subtotal_usd: number + tax: number + tax_formatted: string + tax_inclusive: boolean + tax_usd: number + total: number + total_formatted: string + total_usd: number + updated_at: string + user_email: string + user_name: string + } + id: string + type: string + }[] +} diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue index d17b7c9a..74862f7a 100644 --- a/web/pages/billing/index.vue +++ b/web/pages/billing/index.vue @@ -288,6 +288,76 @@ +
Usage History

Summary of all the sent and received messages in the past 12 @@ -349,6 +419,9 @@ import { mdiDelete, mdiCog, mdiContentSave, + mdiCheck, + mdiAlert, + mdiInvoice, mdiEye, mdiEyeOff, mdiCallReceived, @@ -356,6 +429,10 @@ import { mdiCreditCard, mdiSquareEditOutline, } from '@mdi/js' +import { + EntitiesPhoneAPIKey, + ResponsesSubscriptionInvoicesAPIResponse, +} from '~/models/api' type PaymentPlan = { name: string @@ -373,6 +450,9 @@ export default Vue.extend({ mdiEyeOff, mdiArrowLeft, mdiAccountCircle, + mdiCheck, + mdiAlert, + mdiInvoice, mdiShieldCheck, mdiDelete, mdiCog, @@ -382,7 +462,9 @@ export default Vue.extend({ mdiCreditCard, mdiSquareEditOutline, loading: true, + loadingSubscriptionInvoices: false, dialog: false, + invoices: null as ResponsesSubscriptionInvoicesAPIResponse | null, plans: [ { name: 'Free', @@ -513,7 +595,22 @@ export default Vue.extend({ this.$store.dispatch('loadBillingUsageHistory'), ]) this.loading = false + this.loadSubscriptionInvoices() + }, + + loadSubscriptionInvoices() { + this.loadingSubscriptionInvoices = true + this.$store + .dispatch('indexSubscriptionPayments') + .then((invoices: ResponsesSubscriptionInvoicesAPIResponse) => { + console.log(invoices) + this.invoices = invoices + }) + .finally(() => { + this.loadingSubscriptionInvoices = false + }) }, + updateDetails() { this.loading = true this.$store diff --git a/web/store/index.ts b/web/store/index.ts index 92c15c5d..54fc875a 100644 --- a/web/store/index.ts +++ b/web/store/index.ts @@ -25,7 +25,9 @@ import { ResponsesOkString, ResponsesPhoneAPIKeyResponse, ResponsesPhoneAPIKeysResponse, + ResponsesSubscriptionInvoicesAPIResponse, ResponsesUnprocessableEntity, + ResponsesUserInvoicesResponse, ResponsesUserResponse, ResponsesWebhookResponse, ResponsesWebhooksResponse, @@ -533,6 +535,36 @@ export const actions = { }) }, + indexSubscriptionPayments(context: ActionContext) { + return new Promise( + (resolve, reject) => { + axios + .get( + `/v1/users/subscription/payments`, + { + params: { + limit: 100, + }, + }, + ) + .then((response: AxiosResponse) => { + resolve(response.data.data) + }) + .catch(async (error: AxiosError) => { + await Promise.all([ + context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while fetching subscription payments.', + type: 'error', + }), + ]) + reject(getErrorMessages(error)) + }) + }, + ) + }, + async handleAxiosError( context: ActionContext, error: AxiosError, From 473f511f195de8aff955bdf19f40a99b3bd4bb62 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Wed, 14 Jan 2026 01:13:01 +0200 Subject: [PATCH 003/136] Update swagger --- api/docs/docs.go | 153 ++++++++++++++++++------------------------ api/docs/swagger.json | 135 ++++++++++++++++--------------------- api/docs/swagger.yaml | 125 ++++++++++++++++------------------ 3 files changed, 183 insertions(+), 230 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 60d623e7..f0a3cc7d 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -2363,29 +2363,29 @@ const docTemplate = `{ } } }, - "/users/subscription/invoices": { - "get": { + "/users/subscription/invoices/{subscriptionInvoiceID}": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Subscription invoices are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", + "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.", "consumes": [ "application/json" ], "produces": [ - "application/json" + "application/pdf" ], "tags": [ "Users" ], - "summary": "Get the last 10 subscription invoices.", + "summary": "Generate a subscription invoice", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/responses.UserInvoicesResponse" + "type": "file" } }, "400": { @@ -2415,29 +2415,29 @@ const docTemplate = `{ } } }, - "/users/subscription/invoices/{subscriptionInvoiceID}": { - "post": { + "/users/subscription/payments": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Generates a new invoice for the given subscription with given parameters.", + "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", "consumes": [ "application/json" ], "produces": [ - "application/pdf" + "application/json" ], "tags": [ "Users" ], - "summary": "Generate a subscription invoice", + "summary": "Get the last 10 subscription payments.", "responses": { "200": { "description": "OK", "schema": { - "type": "file" + "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse" } }, "400": { @@ -4318,27 +4318,6 @@ const docTemplate = `{ } } }, - "responses.UserInvoicesResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "$ref": "#/definitions/responses.subscriptionInvoicesAPIResponse" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } - }, "responses.UserResponse": { "type": "object", "required": [ @@ -4360,56 +4339,13 @@ const docTemplate = `{ } } }, - "responses.WebhookResponse": { + "responses.UserSubscriptionPaymentsResponse": { "type": "object", "required": [ "data", "message", "status" ], - "properties": { - "data": { - "$ref": "#/definitions/entities.Webhook" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.WebhooksResponse": { - "type": "object", - "required": [ - "data", - "message", - "status" - ], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Webhook" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.subscriptionInvoicesAPIResponse": { - "type": "object", - "required": [ - "data" - ], "properties": { "data": { "type": "array", @@ -4450,9 +4386,7 @@ const docTemplate = `{ "total", "total_formatted", "total_usd", - "updated_at", - "user_email", - "user_name" + "updated_at" ], "properties": { "billing_reason": { @@ -4533,12 +4467,6 @@ const docTemplate = `{ }, "updated_at": { "type": "string" - }, - "user_email": { - "type": "string" - }, - "user_name": { - "type": "string" } } }, @@ -4550,6 +4478,59 @@ const docTemplate = `{ } } } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.WebhookResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "$ref": "#/definitions/entities.Webhook" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.WebhooksResponse": { + "type": "object", + "required": [ + "data", + "message", + "status" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Webhook" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" } } } diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 35e5a245..88c252d1 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -2145,23 +2145,23 @@ } } }, - "/users/subscription/invoices": { - "get": { + "/users/subscription/invoices/{subscriptionInvoiceID}": { + "post": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Subscription invoices are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", + "description": "Generates a new invoice PDF file for the given subscription payment with given parameters.", "consumes": ["application/json"], - "produces": ["application/json"], + "produces": ["application/pdf"], "tags": ["Users"], - "summary": "Get the last 10 subscription invoices.", + "summary": "Generate a subscription invoice", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/responses.UserInvoicesResponse" + "type": "file" } }, "400": { @@ -2191,23 +2191,23 @@ } } }, - "/users/subscription/invoices/{subscriptionInvoiceID}": { - "post": { + "/users/subscription/payments": { + "get": { "security": [ { "ApiKeyAuth": [] } ], - "description": "Generates a new invoice for the given subscription with given parameters.", + "description": "Subscription payments are generated throughout the lifecycle of a subscription, typically there is one at the time of purchase and then one for each renewal.", "consumes": ["application/json"], - "produces": ["application/pdf"], + "produces": ["application/json"], "tags": ["Users"], - "summary": "Generate a subscription invoice", + "summary": "Get the last 10 subscription payments.", "responses": { "200": { "description": "OK", "schema": { - "type": "file" + "$ref": "#/definitions/responses.UserSubscriptionPaymentsResponse" } }, "400": { @@ -3901,23 +3901,6 @@ } } }, - "responses.UserInvoicesResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/responses.subscriptionInvoicesAPIResponse" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } - }, "responses.UserResponse": { "type": "object", "required": ["data", "message", "status"], @@ -3935,46 +3918,9 @@ } } }, - "responses.WebhookResponse": { - "type": "object", - "required": ["data", "message", "status"], - "properties": { - "data": { - "$ref": "#/definitions/entities.Webhook" - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.WebhooksResponse": { + "responses.UserSubscriptionPaymentsResponse": { "type": "object", "required": ["data", "message", "status"], - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/entities.Webhook" - } - }, - "message": { - "type": "string", - "example": "Request handled successfully" - }, - "status": { - "type": "string", - "example": "success" - } - } - }, - "responses.subscriptionInvoicesAPIResponse": { - "type": "object", - "required": ["data"], "properties": { "data": { "type": "array", @@ -4011,9 +3957,7 @@ "total", "total_formatted", "total_usd", - "updated_at", - "user_email", - "user_name" + "updated_at" ], "properties": { "billing_reason": { @@ -4094,12 +4038,6 @@ }, "updated_at": { "type": "string" - }, - "user_email": { - "type": "string" - }, - "user_name": { - "type": "string" } } }, @@ -4111,6 +4049,51 @@ } } } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.WebhookResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "$ref": "#/definitions/entities.Webhook" + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" + } + } + }, + "responses.WebhooksResponse": { + "type": "object", + "required": ["data", "message", "status"], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/entities.Webhook" + } + }, + "message": { + "type": "string", + "example": "Request handled successfully" + }, + "status": { + "type": "string", + "example": "success" } } } diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index fdde688c..28274519 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -1111,21 +1111,6 @@ definitions: - message - status type: object - responses.UserInvoicesResponse: - properties: - data: - $ref: "#/definitions/responses.subscriptionInvoicesAPIResponse" - message: - example: Request handled successfully - type: string - status: - example: success - type: string - required: - - data - - message - - status - type: object responses.UserResponse: properties: data: @@ -1141,39 +1126,7 @@ definitions: - message - status type: object - responses.WebhookResponse: - properties: - data: - $ref: "#/definitions/entities.Webhook" - message: - example: Request handled successfully - type: string - status: - example: success - type: string - required: - - data - - message - - status - type: object - responses.WebhooksResponse: - properties: - data: - items: - $ref: "#/definitions/entities.Webhook" - type: array - message: - example: Request handled successfully - type: string - status: - example: success - type: string - required: - - data - - message - - status - type: object - responses.subscriptionInvoicesAPIResponse: + responses.UserSubscriptionPaymentsResponse: properties: data: items: @@ -1233,10 +1186,6 @@ definitions: type: integer updated_at: type: string - user_email: - type: string - user_name: - type: string required: - billing_reason - card_brand @@ -1265,8 +1214,6 @@ definitions: - total_formatted - total_usd - updated_at - - user_email - - user_name type: object id: type: string @@ -1278,8 +1225,48 @@ definitions: - type type: object type: array + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object + responses.WebhookResponse: + properties: + data: + $ref: "#/definitions/entities.Webhook" + message: + example: Request handled successfully + type: string + status: + example: success + type: string + required: + - data + - message + - status + type: object + responses.WebhooksResponse: + properties: + data: + items: + $ref: "#/definitions/entities.Webhook" + type: array + message: + example: Request handled successfully + type: string + status: + example: success + type: string required: - data + - message + - status type: object host: api.httpsms.com info: @@ -2936,21 +2923,20 @@ paths: summary: Currently authenticated user subscription update URL tags: - Users - /users/subscription/invoices: - get: + /users/subscription/invoices/{subscriptionInvoiceID}: + post: consumes: - application/json description: - Subscription invoices are generated throughout the lifecycle of - a subscription, typically there is one at the time of purchase and then one - for each renewal. + Generates a new invoice PDF file for the given subscription payment + with given parameters. produces: - - application/json + - application/pdf responses: "200": description: OK schema: - $ref: "#/definitions/responses.UserInvoicesResponse" + type: file "400": description: Bad Request schema: @@ -2969,21 +2955,24 @@ paths: $ref: "#/definitions/responses.InternalServerError" security: - ApiKeyAuth: [] - summary: Get the last 10 subscription invoices. + summary: Generate a subscription invoice tags: - Users - /users/subscription/invoices/{subscriptionInvoiceID}: - post: + /users/subscription/payments: + get: consumes: - application/json - description: Generates a new invoice for the given subscription with given parameters. + description: + Subscription payments are generated throughout the lifecycle of + a subscription, typically there is one at the time of purchase and then one + for each renewal. produces: - - application/pdf + - application/json responses: "200": description: OK schema: - type: file + $ref: "#/definitions/responses.UserSubscriptionPaymentsResponse" "400": description: Bad Request schema: @@ -3002,7 +2991,7 @@ paths: $ref: "#/definitions/responses.InternalServerError" security: - ApiKeyAuth: [] - summary: Generate a subscription invoice + summary: Get the last 10 subscription payments. tags: - Users /webhooks: From ade3a3d64d03db010f7a4fc73bde123131a74574 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Thu, 15 Jan 2026 00:49:51 +0200 Subject: [PATCH 004/136] Add the frontend --- api/docs/docs.go | 55 +- api/docs/swagger.json | 55 +- api/docs/swagger.yaml | 41 +- api/pkg/di/container.go | 2 + api/pkg/handlers/billing_handler.go | 2 +- api/pkg/handlers/user_handler.go | 29 +- .../requests/user_payment_invoice_request.go | 46 ++ api/pkg/services/user_service.go | 48 +- api/pkg/validators/user_handler_validator.go | 94 ++- web/models/api.ts | 65 +- web/pages/billing/index.vue | 561 +++++++++++++++++- web/store/index.ts | 87 ++- 12 files changed, 1016 insertions(+), 69 deletions(-) create mode 100644 api/pkg/requests/user_payment_invoice_request.go diff --git a/api/docs/docs.go b/api/docs/docs.go index f0a3cc7d..d5a3831f 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -2380,7 +2380,18 @@ const docTemplate = `{ "tags": [ "Users" ], - "summary": "Generate a subscription invoice", + "summary": "Generate a subscription payment invoice", + "parameters": [ + { + "description": "Generate subscription payment invoice parameters", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UserPaymentInvoice" + } + } + ], "responses": { "200": { "description": "OK", @@ -3798,6 +3809,48 @@ const docTemplate = `{ } } }, + "requests.UserPaymentInvoice": { + "type": "object", + "required": [ + "address", + "city", + "country", + "name", + "notes", + "state", + "zip_code" + ], + "properties": { + "address": { + "type": "string", + "example": "221B Baker Street, London" + }, + "city": { + "type": "string", + "example": "Los Angeles" + }, + "country": { + "type": "string", + "example": "US" + }, + "name": { + "type": "string", + "example": "Acme Corp" + }, + "notes": { + "type": "string", + "example": "Thank you for your business!" + }, + "state": { + "type": "string", + "example": "CA" + }, + "zip_code": { + "type": "string", + "example": "9800" + } + } + }, "requests.UserUpdate": { "type": "object", "required": [ diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 88c252d1..8559c8ec 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -2156,7 +2156,18 @@ "consumes": ["application/json"], "produces": ["application/pdf"], "tags": ["Users"], - "summary": "Generate a subscription invoice", + "summary": "Generate a subscription payment invoice", + "parameters": [ + { + "description": "Generate subscription payment invoice parameters", + "name": "payload", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UserPaymentInvoice" + } + } + ], "responses": { "200": { "description": "OK", @@ -3477,6 +3488,48 @@ } } }, + "requests.UserPaymentInvoice": { + "type": "object", + "required": [ + "address", + "city", + "country", + "name", + "notes", + "state", + "zip_code" + ], + "properties": { + "address": { + "type": "string", + "example": "221B Baker Street, London" + }, + "city": { + "type": "string", + "example": "Los Angeles" + }, + "country": { + "type": "string", + "example": "US" + }, + "name": { + "type": "string", + "example": "Acme Corp" + }, + "notes": { + "type": "string", + "example": "Thank you for your business!" + }, + "state": { + "type": "string", + "example": "CA" + }, + "zip_code": { + "type": "string", + "example": "9800" + } + } + }, "requests.UserUpdate": { "type": "object", "required": ["active_phone_id", "timezone"], diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 28274519..16811ab3 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -742,6 +742,38 @@ definitions: - newsletter_enabled - webhook_enabled type: object + requests.UserPaymentInvoice: + properties: + address: + example: 221B Baker Street, London + type: string + city: + example: Los Angeles + type: string + country: + example: US + type: string + name: + example: Acme Corp + type: string + notes: + example: Thank you for your business! + type: string + state: + example: CA + type: string + zip_code: + example: "9800" + type: string + required: + - address + - city + - country + - name + - notes + - state + - zip_code + type: object requests.UserUpdate: properties: active_phone_id: @@ -2930,6 +2962,13 @@ paths: description: Generates a new invoice PDF file for the given subscription payment with given parameters. + parameters: + - description: Generate subscription payment invoice parameters + in: body + name: payload + required: true + schema: + $ref: "#/definitions/requests.UserPaymentInvoice" produces: - application/pdf responses: @@ -2955,7 +2994,7 @@ paths: $ref: "#/definitions/responses.InternalServerError" security: - ApiKeyAuth: [] - summary: Generate a subscription invoice + summary: Generate a subscription payment invoice tags: - Users /users/subscription/payments: diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 07bce2c3..d9a912f4 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -648,6 +648,7 @@ func (container *Container) UserHandlerValidator() (validator *validators.UserHa return validators.NewUserHandlerValidator( container.Logger(), container.Tracer(), + container.UserService(), ) } @@ -934,6 +935,7 @@ func (container *Container) UserService() (service *services.UserService) { container.LemonsqueezyClient(), container.EventDispatcher(), container.FirebaseAuthClient(), + container.HTTPClient("lemonsqueezy"), ) } diff --git a/api/pkg/handlers/billing_handler.go b/api/pkg/handlers/billing_handler.go index bcdb5248..3d65ee9a 100644 --- a/api/pkg/handlers/billing_handler.go +++ b/api/pkg/handlers/billing_handler.go @@ -65,7 +65,7 @@ func (h *BillingHandler) UsageHistory(c *fiber.Ctx) error { var request requests.BillingUsageHistory if err := c.QueryParser(&request); err != nil { - msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request) + msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.Body(), request) ctxLogger.Warn(stacktrace.Propagate(err, msg)) return h.responseBadRequest(c, err) } diff --git a/api/pkg/handlers/user_handler.go b/api/pkg/handlers/user_handler.go index 574edf27..2d3895da 100644 --- a/api/pkg/handlers/user_handler.go +++ b/api/pkg/handlers/user_handler.go @@ -46,7 +46,7 @@ func (h *UserHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.H router.Put("/v1/users/:userID/notifications", h.computeRoute(middlewares, h.UpdateNotifications)...) router.Get("/v1/users/subscription-update-url", h.computeRoute(middlewares, h.subscriptionUpdateURL)...) router.Delete("/v1/users/subscription", h.computeRoute(middlewares, h.cancelSubscription)...) - router.Get("/v1/users/subscription/invoices", h.computeRoute(middlewares, h.subscriptionPayments)...) + router.Get("/v1/users/subscription/payments", h.computeRoute(middlewares, h.subscriptionPayments)...) router.Post("/v1/users/subscription/invoices/:subscriptionInvoiceID", h.computeRoute(middlewares, h.subscriptionInvoice)...) } @@ -161,11 +161,9 @@ func (h *UserHandler) Delete(c *fiber.Ctx) error { // @Failure 500 {object} responses.InternalServerError // @Router /users/{userID}/notifications [put] func (h *UserHandler) UpdateNotifications(c *fiber.Ctx) error { - ctx, span := h.tracer.StartFromFiberCtx(c) + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() - ctxLogger := h.tracer.CtxLogger(h.logger, span) - var request requests.UserNotificationUpdate if err := c.BodyParser(&request); err != nil { msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request) @@ -303,12 +301,13 @@ func (h *UserHandler) subscriptionPayments(c *fiber.Ctx) error { } // subscriptionInvoice generates an invoice for a given subscription invoice ID -// @Summary Generate a subscription invoice +// @Summary Generate a subscription payment invoice // @Description Generates a new invoice PDF file for the given subscription payment with given parameters. // @Security ApiKeyAuth // @Tags Users // @Accept json // @Produce application/pdf +// @Param payload body requests.UserPaymentInvoice true "Generate subscription payment invoice parameters" // @Success 200 {file} file // @Failure 400 {object} responses.BadRequest // @Failure 401 {object} responses.Unauthorized @@ -319,17 +318,29 @@ func (h *UserHandler) subscriptionInvoice(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() - invoiceID := c.Params("subscriptionInvoiceID") + var request requests.UserPaymentInvoice + if err := c.BodyParser(&request); err != nil { + msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.Body(), request) + ctxLogger.Warn(stacktrace.Propagate(err, msg)) + return h.responseBadRequest(c, err) + } + + request.SubscriptionInvoiceID = c.Params("subscriptionInvoiceID") + if errors := h.validator.ValidatePaymentInvoice(ctx, h.userIDFomContext(c), request.Sanitize()); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while validating subscription payment invoice request [%s]", spew.Sdump(errors), c.Body()) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while generating payment invoice") + } - data, err := h.service.GenerateReceipt(ctx, h.userIDFomContext(c)) + data, err := h.service.GenerateReceipt(ctx, request.UserInvoiceGenerateParams(h.userIDFomContext(c))) if err != nil { - msg := fmt.Sprintf("cannot generate receipt for invoice ID [%s] and user [%s]", invoiceID, h.userFromContext(c)) + msg := fmt.Sprintf("cannot generate receipt for invoice ID [%s] and user [%s]", request.SubscriptionInvoiceID, h.userFromContext(c)) ctxLogger.Error(stacktrace.Propagate(err, msg)) return h.responseInternalServerError(c) } c.Set(fiber.HeaderContentType, "application/pdf") - c.Set(fiber.HeaderContentDisposition, fmt.Sprintf("attachment; filename=\"%s.pdf\"", invoiceID)) + c.Set(fiber.HeaderContentDisposition, fmt.Sprintf("attachment; filename=\"httpsms.com - %s.pdf\"", request.SubscriptionInvoiceID)) return c.SendStream(data) } diff --git a/api/pkg/requests/user_payment_invoice_request.go b/api/pkg/requests/user_payment_invoice_request.go new file mode 100644 index 00000000..4d196f4a --- /dev/null +++ b/api/pkg/requests/user_payment_invoice_request.go @@ -0,0 +1,46 @@ +package requests + +import ( + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/services" +) + +// UserPaymentInvoice is the payload for generating a subscription payment invoice +type UserPaymentInvoice struct { + request + Name string `json:"name" example:"Acme Corp"` + Address string `json:"address" example:"221B Baker Street, London"` + City string `json:"city" example:"Los Angeles"` + State string `json:"state" example:"CA"` + Country string `json:"country" example:"US"` + ZipCode string `json:"zip_code" example:"9800"` + Notes string `json:"notes" example:"Thank you for your business!"` + SubscriptionInvoiceID string `json:"subscriptionInvoiceID" swaggerignore:"true"` // used internally for validation +} + +// Sanitize sets defaults to MessageReceive +func (input *UserPaymentInvoice) Sanitize() UserPaymentInvoice { + input.Name = input.sanitizeAddress(input.Name) + input.Address = input.sanitizeAddress(input.Address) + input.City = input.sanitizeAddress(input.City) + input.State = input.sanitizeAddress(input.State) + input.Country = input.sanitizeAddress(input.Country) + input.ZipCode = input.sanitizeAddress(input.ZipCode) + input.Notes = input.sanitizeAddress(input.Notes) + return *input +} + +// UserInvoiceGenerateParams converts UserPaymentInvoice to services.UserInvoiceGenerateParams +func (input *UserPaymentInvoice) UserInvoiceGenerateParams(userID entities.UserID) *services.UserInvoiceGenerateParams { + return &services.UserInvoiceGenerateParams{ + UserID: userID, + SubscriptionInvoiceID: input.SubscriptionInvoiceID, + Name: input.Name, + Address: input.Address, + City: input.City, + State: input.State, + Country: input.Country, + Notes: input.Notes, + ZipCode: input.ZipCode, + } +} diff --git a/api/pkg/services/user_service.go b/api/pkg/services/user_service.go index 860e1a42..bd586eaa 100644 --- a/api/pkg/services/user_service.go +++ b/api/pkg/services/user_service.go @@ -4,12 +4,12 @@ import ( "context" "fmt" "io" + "net/http" "time" "firebase.google.com/go/auth" - "github.com/NdoleStudio/httpsms/pkg/events" - "github.com/NdoleStudio/httpsms/pkg/emails" + "github.com/NdoleStudio/httpsms/pkg/events" "github.com/NdoleStudio/lemonsqueezy-go" "github.com/NdoleStudio/httpsms/pkg/repositories" @@ -31,6 +31,7 @@ type UserService struct { dispatcher *EventDispatcher authClient *auth.Client lemonsqueezyClient *lemonsqueezy.Client + httpClient *http.Client } // NewUserService creates a new UserService @@ -43,6 +44,7 @@ func NewUserService( lemonsqueezyClient *lemonsqueezy.Client, dispatcher *EventDispatcher, authClient *auth.Client, + httpClient *http.Client, ) (s *UserService) { return &UserService{ logger: logger.WithService(fmt.Sprintf("%T", s)), @@ -53,6 +55,7 @@ func NewUserService( dispatcher: dispatcher, authClient: authClient, lemonsqueezyClient: lemonsqueezyClient, + httpClient: httpClient, } } @@ -83,13 +86,48 @@ func (service *UserService) GetSubscriptionPayments(ctx context.Context, userID return invoicesResponse.Data, nil } +// UserInvoiceGenerateParams are parameters for generating a subscription payment invoice +type UserInvoiceGenerateParams struct { + UserID entities.UserID + SubscriptionInvoiceID string + Name string + Address string + City string + State string + Country string + ZipCode string + Notes string +} + // GenerateReceipt generates a receipt for a subscription payment. -func (service *UserService) GenerateReceipt(ctx context.Context, userID entities.UserID) (reader io.Reader, err error) { +func (service *UserService) GenerateReceipt(ctx context.Context, params *UserInvoiceGenerateParams) (io.Reader, error) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() - ctxLogger.Info(fmt.Sprintf("generating receipt for user [%s]", userID)) - return nil, nil + payload := map[string]string{ + "name": params.Name, + "address": params.Address, + "city": params.City, + "state": params.State, + "country": params.Country, + "zip_code": params.ZipCode, + "notes": params.Notes, + } + + invoice, _, err := service.lemonsqueezyClient.SubscriptionInvoices.Generate(ctx, params.SubscriptionInvoiceID, payload) + if err != nil { + msg := fmt.Sprintf("could not generate subscription payment invoice user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + response, err := service.httpClient.Get(invoice.Meta.Urls.DownloadInvoice) + if err != nil { + msg := fmt.Sprintf("could not download subscription payment invoice for user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + ctxLogger.Info(fmt.Sprintf("generated subscription payment invoice for user with ID [%s] and subscription invoice ID [%s]", params.UserID, params.SubscriptionInvoiceID)) + return response.Body, nil } // Get fetches or creates an entities.User diff --git a/api/pkg/validators/user_handler_validator.go b/api/pkg/validators/user_handler_validator.go index 553a1cd8..4c05bd1b 100644 --- a/api/pkg/validators/user_handler_validator.go +++ b/api/pkg/validators/user_handler_validator.go @@ -5,26 +5,32 @@ import ( "fmt" "net/url" + "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/services" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/palantir/stacktrace" "github.com/thedevsaddam/govalidator" ) // UserHandlerValidator validates models used in handlers.UserHandler type UserHandlerValidator struct { validator - logger telemetry.Logger - tracer telemetry.Tracer + logger telemetry.Logger + tracer telemetry.Tracer + service *services.UserService } // NewUserHandlerValidator creates a new handlers.UserHandler validator func NewUserHandlerValidator( logger telemetry.Logger, tracer telemetry.Tracer, + service *services.UserService, ) (v *UserHandlerValidator) { return &UserHandlerValidator{ - logger: logger.WithService(fmt.Sprintf("%T", v)), - tracer: tracer, + service: service, + logger: logger.WithService(fmt.Sprintf("%T", v)), + tracer: tracer, } } @@ -41,3 +47,83 @@ func (validator *UserHandlerValidator) ValidateUpdate(_ context.Context, request return v.ValidateStruct() } + +// ValidatePaymentInvoice validates the requests.UserPaymentInvoice request +func (validator *UserHandlerValidator) ValidatePaymentInvoice(ctx context.Context, userID entities.UserID, request requests.UserPaymentInvoice) url.Values { + ctx, span, ctxLogger := validator.tracer.StartWithLogger(ctx, validator.logger) + defer span.End() + + rules := govalidator.MapData{ + "name": []string{ + "required", + "min:1", + "max:100", + }, + "address": []string{ + "required", + "min:1", + "max:200", + }, + "city": []string{ + "required", + "min:1", + "max:100", + }, + "state": []string{ + "min:1", + "max:100", + }, + "country": []string{ + "required", + "len:2", + }, + "zip_code": []string{ + "required", + "min:1", + "max:20", + }, + "notes": []string{ + "max:1000", + }, + } + if request.Country == "CA" { + rules["state"] = []string{ + "required", + "in:AB,BC,MB,NB,NL,NS,NT,NU,ON,PE,QC,SK,YT", + } + } + + if request.Country == "US" { + rules["state"] = []string{ + "required", + "in:AL,AK,AZ,AR,CA,CO,CT,DE,FL,GA,HI,ID,IL,IN,IA,KS,KY,LA,ME,MD,MA,MI,MN,MS,MO,MT,NE,NV,NH,NJ,NM,NY,NC,ND,OH,OK,OR,PA,RI,SC,SD,TN,TX,UT,VT,VA,WA,WV,WI,WY", + } + } + + v := govalidator.New(govalidator.Options{ + Data: &request, + Rules: rules, + }) + + validationErrors := v.ValidateStruct() + if len(validationErrors) > 0 { + return validationErrors + } + + payments, err := validator.service.GetSubscriptionPayments(ctx, userID) + if err != nil { + msg := fmt.Sprintf("cannot get subscription payments for user with ID [%s]", userID) + ctxLogger.Error(validator.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) + validationErrors.Add("subscriptionInvoiceID", "failed to validate subscription payment invoice ID") + return validationErrors + } + + for _, payment := range payments { + if payment.ID == request.SubscriptionInvoiceID { + return validationErrors + } + } + + validationErrors.Add("subscriptionInvoiceID", "failed to validate the subscription payment invoice ID") + return validationErrors +} diff --git a/web/models/api.ts b/web/models/api.ts index 99f15064..94feb0b0 100644 --- a/web/models/api.ts +++ b/web/models/api.ts @@ -427,6 +427,23 @@ export interface RequestsUserNotificationUpdate { webhook_enabled: boolean } +export interface RequestsUserPaymentInvoice { + /** @example "221B Baker Street, London" */ + address: string + /** @example "Los Angeles" */ + city: string + /** @example "US" */ + country: string + /** @example "Acme Corp" */ + name: string + /** @example "Thank you for your business!" */ + notes: string + /** @example "CA" */ + state: string + /** @example "9800" */ + zip_code: string +} + export interface RequestsUserUpdate { /** @example "32343a19-da5e-4b1b-a767-3298a73703cb" */ active_phone_id: string @@ -609,14 +626,6 @@ export interface ResponsesUnprocessableEntity { status: string } -export interface ResponsesUserInvoicesResponse { - data: ResponsesSubscriptionInvoicesAPIResponse - /** @example "Request handled successfully" */ - message: string - /** @example "success" */ - status: string -} - export interface ResponsesUserResponse { data: EntitiesUser /** @example "Request handled successfully" */ @@ -625,23 +634,7 @@ export interface ResponsesUserResponse { status: string } -export interface ResponsesWebhookResponse { - data: EntitiesWebhook - /** @example "Request handled successfully" */ - message: string - /** @example "success" */ - status: string -} - -export interface ResponsesWebhooksResponse { - data: EntitiesWebhook[] - /** @example "Request handled successfully" */ - message: string - /** @example "success" */ - status: string -} - -export interface ResponsesSubscriptionInvoicesAPIResponse { +export interface ResponsesUserSubscriptionPaymentsResponse { data: { attributes: { billing_reason: string @@ -671,10 +664,28 @@ export interface ResponsesSubscriptionInvoicesAPIResponse { total_formatted: string total_usd: number updated_at: string - user_email: string - user_name: string } id: string type: string }[] + /** @example "Request handled successfully" */ + message: string + /** @example "success" */ + status: string +} + +export interface ResponsesWebhookResponse { + data: EntitiesWebhook + /** @example "Request handled successfully" */ + message: string + /** @example "success" */ + status: string +} + +export interface ResponsesWebhooksResponse { + data: EntitiesWebhook[] + /** @example "Request handled successfully" */ + message: string + /** @example "success" */ + status: string } diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue index 74862f7a..750dc1ae 100644 --- a/web/pages/billing/index.vue +++ b/web/pages/billing/index.vue @@ -300,16 +300,19 @@ >.

- + @@ -417,6 +571,7 @@ import { mdiAccountCircle, mdiShieldCheck, mdiDelete, + mdiDownloadOutline, mdiCog, mdiContentSave, mdiCheck, @@ -430,9 +585,12 @@ import { mdiSquareEditOutline, } from '@mdi/js' import { - EntitiesPhoneAPIKey, - ResponsesSubscriptionInvoicesAPIResponse, + RequestsUserPaymentInvoice, + ResponsesUnprocessableEntity, + ResponsesUserSubscriptionPaymentsResponse, } from '~/models/api' +import { ErrorMessages, getErrorMessages } from '~/plugins/errors' +import { AxiosError } from 'axios' type PaymentPlan = { name: string @@ -441,6 +599,14 @@ type PaymentPlan = { messagesPerMonth: number } +type subscriptionPayment = { + attributes: { + created_at: string + total_formatted: string + } + id: string +} + export default Vue.extend({ name: 'BillingIndex', middleware: ['auth'], @@ -449,6 +615,7 @@ export default Vue.extend({ mdiEye, mdiEyeOff, mdiArrowLeft, + mdiDownloadOutline, mdiAccountCircle, mdiCheck, mdiAlert, @@ -462,9 +629,267 @@ export default Vue.extend({ mdiCreditCard, mdiSquareEditOutline, loading: true, - loadingSubscriptionInvoices: false, + loadingSubscriptionPayments: false, dialog: false, - invoices: null as ResponsesSubscriptionInvoicesAPIResponse | null, + payments: null as ResponsesUserSubscriptionPaymentsResponse | null, + selectedPayment: null as subscriptionPayment | null, + errorMessages: new ErrorMessages(), + invoiceFormName: '', + invoiceFormAddress: '', + invoiceFormCity: '', + invoiceFormState: '', + invoiceFormZipCode: '', + invoiceFormCountry: '', + invoiceFormNotes: '', + subscriptionInvoiceDialog: false, + countries: [ + { text: 'Afghanistan', value: 'AF' }, + { text: 'Åland Islands', value: 'AX' }, + { text: 'Albania', value: 'AL' }, + { text: 'Algeria', value: 'DZ' }, + { text: 'American Samoa', value: 'AS' }, + { text: 'Andorra', value: 'AD' }, + { text: 'Angola', value: 'AO' }, + { text: 'Anguilla', value: 'AI' }, + { text: 'Antarctica', value: 'AQ' }, + { text: 'Antigua and Barbuda', value: 'AG' }, + { text: 'Argentina', value: 'AR' }, + { text: 'Armenia', value: 'AM' }, + { text: 'Aruba', value: 'AW' }, + { text: 'Australia', value: 'AU' }, + { text: 'Austria', value: 'AT' }, + { text: 'Azerbaijan', value: 'AZ' }, + { text: 'Bahamas', value: 'BS' }, + { text: 'Bahrain', value: 'BH' }, + { text: 'Bangladesh', value: 'BD' }, + { text: 'Barbados', value: 'BB' }, + { text: 'Belarus', value: 'BY' }, + { text: 'Belgium', value: 'BE' }, + { text: 'Belize', value: 'BZ' }, + { text: 'Benin', value: 'BJ' }, + { text: 'Bermuda', value: 'BM' }, + { text: 'Bhutan', value: 'BT' }, + { text: 'Bolivia', value: 'BO' }, + { text: 'Bonaire', value: 'BQ' }, + { text: 'Bosnia and Herzegovina', value: 'BA' }, + { text: 'Botswana', value: 'BW' }, + { text: 'Bouvet Island', value: 'BV' }, + { text: 'Brazil', value: 'BR' }, + { text: 'British Indian Ocean', value: 'IO' }, + { text: 'Brunei Darussalam', value: 'BN' }, + { text: 'Bulgaria', value: 'BG' }, + { text: 'Burkina Faso', value: 'BF' }, + { text: 'Burundi', value: 'BI' }, + { text: 'Cabo Verde', value: 'CV' }, + { text: 'Cambodia', value: 'KH' }, + { text: 'Cameroon', value: 'CM' }, + { text: 'Canada', value: 'CA' }, + { text: 'Cayman Islands', value: 'KY' }, + { text: 'Central African Republic', value: 'CF' }, + { text: 'Chad', value: 'TD' }, + { text: 'Chile', value: 'CL' }, + { text: 'China', value: 'CN' }, + { text: 'Christmas Island', value: 'CX' }, + { text: 'Cocos (Keeling) Islands', value: 'CC' }, + { text: 'Colombia', value: 'CO' }, + { text: 'Comoros', value: 'KM' }, + { text: 'Congo', value: 'CG' }, + { text: 'Congo', value: 'CD' }, + { text: 'Cook Islands', value: 'CK' }, + { text: 'Costa Rica', value: 'CR' }, + { text: "Côte d'Ivoire", value: 'CI' }, + { text: 'Cuba', value: 'CU' }, + { text: 'Curaçao', value: 'CW' }, + { text: 'Cyprus', value: 'CY' }, + { text: 'Czechia', value: 'CZ' }, + { text: 'Denmark', value: 'DK' }, + { text: 'Djibouti', value: 'DJ' }, + { text: 'Dominica', value: 'DM' }, + { text: 'Dominican Republic', value: 'DO' }, + { text: 'Ecuador', value: 'EC' }, + { text: 'Egypt', value: 'EG' }, + { text: 'El Salvador', value: 'SV' }, + { text: 'Equatorial Guinea', value: 'GQ' }, + { text: 'Eritrea', value: 'ER' }, + { text: 'Estonia', value: 'EE' }, + { text: 'Eswatini', value: 'SZ' }, + { text: 'Ethiopia', value: 'ET' }, + { text: 'Falkland Islands', value: 'FK' }, + { text: 'Faroe Islands', value: 'FO' }, + { text: 'Fiji', value: 'FJ' }, + { text: 'Finland', value: 'FI' }, + { text: 'France', value: 'FR' }, + { text: 'French Guiana', value: 'GF' }, + { text: 'French Polynesia', value: 'PF' }, + { text: 'French Southern Territories', value: 'TF' }, + { text: 'Gabon', value: 'GA' }, + { text: 'Gambia', value: 'GM' }, + { text: 'Georgia', value: 'GE' }, + { text: 'Germany', value: 'DE' }, + { text: 'Ghana', value: 'GH' }, + { text: 'Gibraltar', value: 'GI' }, + { text: 'Greece', value: 'GR' }, + { text: 'Greenland', value: 'GL' }, + { text: 'Grenada', value: 'GD' }, + { text: 'Guadeloupe', value: 'GP' }, + { text: 'Guam', value: 'GU' }, + { text: 'Guatemala', value: 'GT' }, + { text: 'Guernsey', value: 'GG' }, + { text: 'Guinea', value: 'GN' }, + { text: 'Guinea-Bissau', value: 'GW' }, + { text: 'Guyana', value: 'GY' }, + { text: 'Haiti', value: 'HT' }, + { text: 'Heard Island and McDonald Islands', value: 'HM' }, + { text: 'Holy See', value: 'VA' }, + { text: 'Honduras', value: 'HN' }, + { text: 'Hong Kong', value: 'HK' }, + { text: 'Hungary', value: 'HU' }, + { text: 'Iceland', value: 'IS' }, + { text: 'India', value: 'IN' }, + { text: 'Indonesia', value: 'ID' }, + { text: 'Iran', value: 'IR' }, + { text: 'Iraq', value: 'IQ' }, + { text: 'Ireland', value: 'IE' }, + { text: 'Isle of Man', value: 'IM' }, + { text: 'Israel', value: 'IL' }, + { text: 'Italy', value: 'IT' }, + { text: 'Jamaica', value: 'JM' }, + { text: 'Japan', value: 'JP' }, + { text: 'Jersey', value: 'JE' }, + { text: 'Jordan', value: 'JO' }, + { text: 'Kazakhstan', value: 'KZ' }, + { text: 'Kenya', value: 'KE' }, + { text: 'Kiribati', value: 'KI' }, + { text: 'North Korea', value: 'KP' }, + { text: 'South Korea', value: 'KR' }, + { text: 'Kuwait', value: 'KW' }, + { text: 'Kyrgyzstan', value: 'KG' }, + { text: 'Lao People’s Democratic Republic', value: 'LA' }, + { text: 'Latvia', value: 'LV' }, + { text: 'Lebanon', value: 'LB' }, + { text: 'Lesotho', value: 'LS' }, + { text: 'Liberia', value: 'LR' }, + { text: 'Libya', value: 'LY' }, + { text: 'Liechtenstein', value: 'LI' }, + { text: 'Lithuania', value: 'LT' }, + { text: 'Luxembourg', value: 'LU' }, + { text: 'Macao', value: 'MO' }, + { text: 'Madagascar', value: 'MG' }, + { text: 'Malawi', value: 'MW' }, + { text: 'Malaysia', value: 'MY' }, + { text: 'Maldives', value: 'MV' }, + { text: 'Mali', value: 'ML' }, + { text: 'Malta', value: 'MT' }, + { text: 'Marshall Islands', value: 'MH' }, + { text: 'Martinique', value: 'MQ' }, + { text: 'Mauritania', value: 'MR' }, + { text: 'Mauritius', value: 'MU' }, + { text: 'Mayotte', value: 'YT' }, + { text: 'Mexico', value: 'MX' }, + { text: 'Micronesia', value: 'FM' }, + { text: 'Moldova', value: 'MD' }, + { text: 'Monaco', value: 'MC' }, + { text: 'Mongolia', value: 'MN' }, + { text: 'Montenegro', value: 'ME' }, + { text: 'Montserrat', value: 'MS' }, + { text: 'Morocco', value: 'MA' }, + { text: 'Mozambique', value: 'MZ' }, + { text: 'Myanmar', value: 'MM' }, + { text: 'Namibia', value: 'NA' }, + { text: 'Nauru', value: 'NR' }, + { text: 'Nepal', value: 'NP' }, + { text: 'Netherlands', value: 'NL' }, + { text: 'New Caledonia', value: 'NC' }, + { text: 'New Zealand', value: 'NZ' }, + { text: 'Nicaragua', value: 'NI' }, + { text: 'Niger', value: 'NE' }, + { text: 'Nigeria', value: 'NG' }, + { text: 'Niue', value: 'NU' }, + { text: 'Norfolk Island', value: 'NF' }, + { text: 'North Macedonia', value: 'MK' }, + { text: 'Northern Mariana Islands', value: 'MP' }, + { text: 'Norway', value: 'NO' }, + { text: 'Oman', value: 'OM' }, + { text: 'Pakistan', value: 'PK' }, + { text: 'Palau', value: 'PW' }, + { text: 'Panama', value: 'PA' }, + { text: 'Papua New Guinea', value: 'PG' }, + { text: 'Paraguay', value: 'PY' }, + { text: 'Peru', value: 'PE' }, + { text: 'Philippines', value: 'PH' }, + { text: 'Pitcairn', value: 'PN' }, + { text: 'Poland', value: 'PL' }, + { text: 'Portugal', value: 'PT' }, + { text: 'Puerto Rico', value: 'PR' }, + { text: 'Qatar', value: 'QA' }, + { text: 'Réunion', value: 'RE' }, + { text: 'Romania', value: 'RO' }, + { text: 'Russian Federation', value: 'RU' }, + { text: 'Rwanda', value: 'RW' }, + { text: 'Saint Barthélemy', value: 'BL' }, + { text: 'Saint Helena, Ascension and Tristan da Cunha', value: 'SH' }, + { text: 'Saint Kitts and Nevis', value: 'KN' }, + { text: 'Saint Lucia', value: 'LC' }, + { text: 'Saint Martin (French part)', value: 'MF' }, + { text: 'Saint Pierre and Miquelon', value: 'PM' }, + { text: 'Saint Vincent and the Grenadines', value: 'VC' }, + { text: 'Samoa', value: 'WS' }, + { text: 'San Marino', value: 'SM' }, + { text: 'Sao Tome and Principe', value: 'ST' }, + { text: 'Saudi Arabia', value: 'SA' }, + { text: 'Senegal', value: 'SN' }, + { text: 'Serbia', value: 'RS' }, + { text: 'Seychelles', value: 'SC' }, + { text: 'Sierra Leone', value: 'SL' }, + { text: 'Singapore', value: 'SG' }, + { text: 'Slovakia', value: 'SK' }, + { text: 'Slovenia', value: 'SI' }, + { text: 'Solomon Islands', value: 'SB' }, + { text: 'Somalia', value: 'SO' }, + { text: 'South Africa', value: 'ZA' }, + { text: 'South Georgia and the South Sandwich Islands', value: 'GS' }, + { text: 'South Sudan', value: 'SS' }, + { text: 'Spain', value: 'ES' }, + { text: 'Sri Lanka', value: 'LK' }, + { text: 'Sudan', value: 'SD' }, + { text: 'Suriname', value: 'SR' }, + { text: 'Svalbard and Jan Mayen', value: 'SJ' }, + { text: 'Sweden', value: 'SE' }, + { text: 'Switzerland', value: 'CH' }, + { text: 'Syrian Arab Republic', value: 'SY' }, + { text: 'Taiwan, Province of China', value: 'TW' }, + { text: 'Tajikistan', value: 'TJ' }, + { text: 'Tanzania, United Republic of', value: 'TZ' }, + { text: 'Thailand', value: 'TH' }, + { text: 'Timor-Leste', value: 'TL' }, + { text: 'Togo', value: 'TG' }, + { text: 'Tokelau', value: 'TK' }, + { text: 'Tonga', value: 'TO' }, + { text: 'Trinidad and Tobago', value: 'TT' }, + { text: 'Tunisia', value: 'TN' }, + { text: 'Turkey', value: 'TR' }, + { text: 'Turkmenistan', value: 'TM' }, + { text: 'Turks and Caicos Islands', value: 'TC' }, + { text: 'Tuvalu', value: 'TV' }, + { text: 'Uganda', value: 'UG' }, + { text: 'Ukraine', value: 'UA' }, + { text: 'United Arab Emirates', value: 'AE' }, + { text: 'United Kingdom', value: 'GB' }, + { text: 'United States', value: 'US' }, + { text: 'United States Minor Outlying Islands', value: 'UM' }, + { text: 'Uruguay', value: 'UY' }, + { text: 'Uzbekistan', value: 'UZ' }, + { text: 'Vanuatu', value: 'VU' }, + { text: 'Venezuela', value: 'VE' }, + { text: 'Viet Nam', value: 'VN' }, + { text: 'Virgin Islands (British)', value: 'VG' }, + { text: 'Virgin Islands (U.S.)', value: 'VI' }, + { text: 'Wallis and Futuna', value: 'WF' }, + { text: 'Western Sahara', value: 'EH' }, + { text: 'Yemen', value: 'YE' }, + { text: 'Zambia', value: 'ZM' }, + { text: 'Zimbabwe', value: 'ZW' }, + ], plans: [ { name: 'Free', @@ -541,6 +966,81 @@ export default Vue.extend({ } }, computed: { + invoiceStateOptions() { + if (this.invoiceFormCountry === 'US') { + return [ + { text: 'Alabama', value: 'AL' }, + { text: 'Alaska', value: 'AK' }, + { text: 'Arizona', value: 'AZ' }, + { text: 'Arkansas', value: 'AR' }, + { text: 'California', value: 'CA' }, + { text: 'Colorado', value: 'CO' }, + { text: 'Connecticut', value: 'CT' }, + { text: 'Delaware', value: 'DE' }, + { text: 'Florida', value: 'FL' }, + { text: 'Georgia', value: 'GA' }, + { text: 'Hawaii', value: 'HI' }, + { text: 'Idaho', value: 'ID' }, + { text: 'Illinois', value: 'IL' }, + { text: 'Indiana', value: 'IN' }, + { text: 'Iowa', value: 'IA' }, + { text: 'Kansas', value: 'KS' }, + { text: 'Kentucky', value: 'KY' }, + { text: 'Louisiana', value: 'LA' }, + { text: 'Maine', value: 'ME' }, + { text: 'Maryland', value: 'MD' }, + { text: 'Massachusetts', value: 'MA' }, + { text: 'Michigan', value: 'MI' }, + { text: 'Minnesota', value: 'MN' }, + { text: 'Mississippi', value: 'MS' }, + { text: 'Missouri', value: 'MO' }, + { text: 'Montana', value: 'MT' }, + { text: 'Nebraska', value: 'NE' }, + { text: 'Nevada', value: 'NV' }, + { text: 'New Hampshire', value: 'NH' }, + { text: 'New Jersey', value: 'NJ' }, + { text: 'New Mexico', value: 'NM' }, + { text: 'New York', value: 'NY' }, + { text: 'North Carolina', value: 'NC' }, + { text: 'North Dakota', value: 'ND' }, + { text: 'Ohio', value: 'OH' }, + { text: 'Oklahoma', value: 'OK' }, + { text: 'Oregon', value: 'OR' }, + { text: 'Pennsylvania', value: 'PA' }, + { text: 'Rhode Island', value: 'RI' }, + { text: 'South Carolina', value: 'SC' }, + { text: 'South Dakota', value: 'SD' }, + { text: 'Tennessee', value: 'TN' }, + { text: 'Texas', value: 'TX' }, + { text: 'Utah', value: 'UT' }, + { text: 'Vermont', value: 'VT' }, + { text: 'Virginia', value: 'VA' }, + { text: 'Washington', value: 'WA' }, + { text: 'West Virginia', value: 'WV' }, + { text: 'Wisconsin', value: 'WI' }, + { text: 'Wyoming', value: 'WY' }, + { text: 'District of Columbia', value: 'DC' }, + ] + } + if (this.invoiceFormCountry === 'CA') { + return [ + { text: 'Alberta', value: 'AB' }, + { text: 'British Columbia', value: 'BC' }, + { text: 'Manitoba', value: 'MB' }, + { text: 'New Brunswick', value: 'NB' }, + { text: 'Newfoundland and Labrador', value: 'NL' }, + { text: 'Nova Scotia', value: 'NS' }, + { text: 'Ontario', value: 'ON' }, + { text: 'Prince Edward Island', value: 'PE' }, + { text: 'Quebec', value: 'QC' }, + { text: 'Saskatchewan', value: 'SK' }, + { text: 'Northwest Territories', value: 'NT' }, + { text: 'Nunavut', value: 'NU' }, + { text: 'Yukon', value: 'YT' }, + ] + } + return [] + }, checkoutURL() { const url = new URL(this.$config.checkoutURL) const user = this.$store.getters.getAuthUser @@ -599,15 +1099,45 @@ export default Vue.extend({ }, loadSubscriptionInvoices() { - this.loadingSubscriptionInvoices = true + this.loadingSubscriptionPayments = true this.$store .dispatch('indexSubscriptionPayments') - .then((invoices: ResponsesSubscriptionInvoicesAPIResponse) => { - console.log(invoices) - this.invoices = invoices + .then((response: ResponsesUserSubscriptionPaymentsResponse) => { + this.payments = response + }) + .finally(() => { + this.loadingSubscriptionPayments = false + }) + }, + + generateInvoice() { + this.errorMessages = new ErrorMessages() + this.loading = true + this.$store + .dispatch('generateSubscriptionPaymentInvoice', { + subscriptionInvoiceId: this.selectedPayment?.id!, + request: { + name: this.invoiceFormName, + address: this.invoiceFormAddress, + city: this.invoiceFormCity, + state: this.invoiceFormState, + zip_code: this.invoiceFormZipCode, + country: this.invoiceFormCountry, + notes: this.invoiceFormNotes, + }, + } as { + subscriptionInvoiceId: string + request: RequestsUserPaymentInvoice + }) + .then(() => { + this.subscriptionInvoiceDialog = false + }) + .catch((error: ErrorMessages) => { + console.log(error) + this.errorMessages = error }) .finally(() => { - this.loadingSubscriptionInvoices = false + this.loading = false }) }, @@ -637,6 +1167,11 @@ export default Vue.extend({ this.loading = false }) }, + + showInvoiceDialog(payment: subscriptionPayment) { + this.selectedPayment = payment + this.subscriptionInvoiceDialog = true + }, }, }) diff --git a/web/store/index.ts b/web/store/index.ts index 54fc875a..afeeaebd 100644 --- a/web/store/index.ts +++ b/web/store/index.ts @@ -16,6 +16,7 @@ import { RequestsDiscordStore, RequestsDiscordUpdate, RequestsUserNotificationUpdate, + RequestsUserPaymentInvoice, RequestsWebhookStore, RequestsWebhookUpdate, ResponsesDiscordResponse, @@ -25,10 +26,9 @@ import { ResponsesOkString, ResponsesPhoneAPIKeyResponse, ResponsesPhoneAPIKeysResponse, - ResponsesSubscriptionInvoicesAPIResponse, ResponsesUnprocessableEntity, - ResponsesUserInvoicesResponse, ResponsesUserResponse, + ResponsesUserSubscriptionPaymentsResponse, ResponsesWebhookResponse, ResponsesWebhooksResponse, } from '~/models/api' @@ -536,10 +536,10 @@ export const actions = { }, indexSubscriptionPayments(context: ActionContext) { - return new Promise( + return new Promise( (resolve, reject) => { axios - .get( + .get( `/v1/users/subscription/payments`, { params: { @@ -547,9 +547,13 @@ export const actions = { }, }, ) - .then((response: AxiosResponse) => { - resolve(response.data.data) - }) + .then( + ( + response: AxiosResponse, + ) => { + resolve(response.data) + }, + ) .catch(async (error: AxiosError) => { await Promise.all([ context.dispatch('addNotification', { @@ -565,6 +569,75 @@ export const actions = { ) }, + generateSubscriptionPaymentInvoice( + context: ActionContext, + payload: { + subscriptionInvoiceId: string + request: RequestsUserPaymentInvoice + }, + ) { + return new Promise((resolve, reject) => { + axios + .post( + `/v1/users/subscription/invoices/${payload.subscriptionInvoiceId}`, + payload.request, + { + responseType: 'blob', + }, + ) + .then(async (response: AxiosResponse) => { + // Create a Blob from the response data + const pdfBlob = new Blob([response.data], { + type: response.headers['content-type'], + }) + + // Create a temporary URL for the Blob + const url = window.URL.createObjectURL(pdfBlob) + + // Create a temporary element to trigger the download + const tempLink = document.createElement('a') + tempLink.href = url + tempLink.setAttribute( + 'download', + response.headers['content-disposition'] + ?.split('filename=')[1] + .replaceAll('"', '') || 'Invoice.pdf', + ) // Set the desired filename for the downloaded file + + // Append the element to the body and click it to trigger the download + document.body.appendChild(tempLink) + tempLink.click() + + // Clean up the temporary elements and URL + document.body.removeChild(tempLink) + window.URL.revokeObjectURL(url) + + await context.dispatch('addNotification', { + message: + response.data.message ?? + 'Your invoice has been generated successfully', + type: 'success', + }) + resolve() + }) + .catch(async (error: AxiosError) => { + const text = await (error.response as any).data.text() + if (error.response) { + error.response.data = JSON.parse(text) + } + await Promise.all([ + context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while generating your invoice', + type: 'error', + }), + ]) + reject(getErrorMessages(error)) + }) + }) + }, + async handleAxiosError( context: ActionContext, error: AxiosError, From 5472c50f75fde1c75cb775fb4b14530e19d0b0fc Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Thu, 15 Jan 2026 00:56:22 +0200 Subject: [PATCH 005/136] Fix the billing usages page --- web/pages/billing/index.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue index 750dc1ae..621f261f 100644 --- a/web/pages/billing/index.vue +++ b/web/pages/billing/index.vue @@ -226,7 +226,7 @@ -
Overview
+
Overview

This is the summary of the sent messages and received messages in -

Usage History
+
Usage History

Summary of all the sent and received messages in the past 12 months From f6e5fc898d2a385564e9f30e0f9e6b4261603c5e Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Thu, 15 Jan 2026 01:03:09 +0200 Subject: [PATCH 006/136] Fix prettier --- web/pages/billing/index.vue | 23 ++++++++++------------- web/pages/index.vue | 10 +++++----- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/web/pages/billing/index.vue b/web/pages/billing/index.vue index 621f261f..039f129f 100644 --- a/web/pages/billing/index.vue +++ b/web/pages/billing/index.vue @@ -435,11 +435,11 @@ { - console.log(error) this.errorMessages = error }) .finally(() => { diff --git a/web/pages/index.vue b/web/pages/index.vue index 0171ce06..f40028cb 100644 --- a/web/pages/index.vue +++ b/web/pages/index.vue @@ -682,7 +682,7 @@ Console.WriteLine(await response.Content.ReadAsStringAsync()); tick-size="4" tick > -