feat: Implement Payment Methods, Shipping Improvements, Swagger Audit, and UUIDv7 Migration

- Payment Methods: Added Pix/Credit/Debit selection in checkout, updated backend models and handlers.

- Shipping: Updated Checkout UI, added shipping_settings table and seed data.

- Swagger: Updated API docs, regenerated swagger.yaml.

- UUIDv7: Migrated seeder and backend tests to use uuid.NewV7().
This commit is contained in:
Tiago Yamamoto 2025-12-26 17:48:50 -03:00
parent fd305c00a8
commit ed4349a938
17 changed files with 1414 additions and 428 deletions

View file

@ -9,15 +9,124 @@ const docTemplate = `{
"info": {
"description": "{{escape .Description}}",
"title": "{{.Title}}",
"contact": {
"name": "Engenharia SaveInMed",
"email": "devops@saveinmed.com"
},
"contact": {},
"version": "{{.Version}}"
},
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/api/v1/admin/reviews": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Lista todas as avaliações (Admin)",
"parameters": [
{
"type": "integer",
"description": "Página",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Tamanho da página",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ReviewPage"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/admin/shipments": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Lista todos os envios (Admin)",
"parameters": [
{
"type": "integer",
"description": "Página",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Tamanho da página",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ShipmentPage"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/auth/login": {
"post": {
"description": "Autentica usuário e retorna token JWT.",
@ -1506,6 +1615,60 @@ const docTemplate = `{
}
},
"/api/v1/reviews": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Lista todas as avaliações (Admin)",
"parameters": [
{
"type": "integer",
"description": "Página",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Tamanho da página",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ReviewPage"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"post": {
"security": [
{
@ -1553,6 +1716,60 @@ const docTemplate = `{
}
},
"/api/v1/shipments": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Lista todos os envios (Admin)",
"parameters": [
{
"type": "integer",
"description": "Página",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Tamanho da página",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ShipmentPage"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"post": {
"security": [
{
@ -1701,10 +1918,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.ShippingMethod"
}
"$ref": "#/definitions/domain.ShippingSettings"
}
},
"400": {
@ -1770,10 +1984,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.ShippingMethod"
}
"$ref": "#/definitions/domain.ShippingSettings"
}
},
"400": {
@ -2225,11 +2436,15 @@ const docTemplate = `{
"type": "string"
},
"created_at": {
"description": "Timestamps",
"type": "string"
},
"id": {
"type": "string"
},
"is_24_hours": {
"type": "boolean"
},
"is_verified": {
"type": "boolean"
},
@ -2243,6 +2458,14 @@ const docTemplate = `{
"longitude": {
"type": "number"
},
"operating_hours": {
"description": "e.g. \"Seg-Sex: 08:00-18:00, Sab: 08:00-12:00\"",
"type": "string"
},
"phone": {
"description": "Contact \u0026 Hours",
"type": "string"
},
"state": {
"type": "string"
},
@ -2312,6 +2535,9 @@ const docTemplate = `{
"$ref": "#/definitions/domain.OrderItem"
}
},
"payment_method": {
"$ref": "#/definitions/domain.PaymentMethod"
},
"seller_id": {
"type": "string"
},
@ -2370,6 +2596,19 @@ const docTemplate = `{
"OrderStatusDelivered"
]
},
"domain.PaymentMethod": {
"type": "string",
"enum": [
"pix",
"credit_card",
"debit_card"
],
"x-enum-varnames": [
"PaymentMethodPix",
"PaymentMethodCredit",
"PaymentMethodDebit"
]
},
"domain.PaymentPreference": {
"type": "object",
"properties": {
@ -2445,21 +2684,33 @@ const docTemplate = `{
"batch": {
"type": "string"
},
"category": {
"type": "string"
},
"created_at": {
"type": "string"
},
"description": {
"type": "string"
},
"ean_code": {
"type": "string"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "string"
},
"manufacturer": {
"type": "string"
},
"name": {
"type": "string"
},
"observations": {
"type": "string"
},
"price_cents": {
"type": "integer"
},
@ -2469,6 +2720,9 @@ const docTemplate = `{
"stock": {
"type": "integer"
},
"subcategory": {
"type": "string"
},
"updated_at": {
"type": "string"
}
@ -2520,6 +2774,9 @@ const docTemplate = `{
"batch": {
"type": "string"
},
"category": {
"type": "string"
},
"created_at": {
"type": "string"
},
@ -2529,15 +2786,24 @@ const docTemplate = `{
"distance_km": {
"type": "number"
},
"ean_code": {
"type": "string"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "string"
},
"manufacturer": {
"type": "string"
},
"name": {
"type": "string"
},
"observations": {
"type": "string"
},
"price_cents": {
"type": "integer"
},
@ -2547,6 +2813,9 @@ const docTemplate = `{
"stock": {
"type": "integer"
},
"subcategory": {
"type": "string"
},
"tenant_city": {
"type": "string"
},
@ -2584,6 +2853,26 @@ const docTemplate = `{
}
}
},
"domain.ReviewPage": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"reviews": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.Review"
}
},
"total": {
"type": "integer"
}
}
},
"domain.SellerDashboard": {
"type": "object",
"properties": {
@ -2639,6 +2928,26 @@ const docTemplate = `{
}
}
},
"domain.ShipmentPage": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"shipments": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.Shipment"
}
},
"total": {
"type": "integer"
}
}
},
"domain.ShippingAddress": {
"type": "object",
"properties": {
@ -2671,63 +2980,6 @@ const docTemplate = `{
}
}
},
"domain.ShippingMethod": {
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"free_shipping_threshold_cents": {
"type": "integer"
},
"id": {
"type": "string"
},
"max_radius_km": {
"type": "number"
},
"min_fee_cents": {
"type": "integer"
},
"pickup_address": {
"type": "string"
},
"pickup_hours": {
"type": "string"
},
"preparation_minutes": {
"type": "integer"
},
"price_per_km_cents": {
"type": "integer"
},
"type": {
"$ref": "#/definitions/domain.ShippingMethodType"
},
"updated_at": {
"type": "string"
},
"vendor_id": {
"type": "string"
}
}
},
"domain.ShippingMethodType": {
"type": "string",
"enum": [
"pickup",
"own_delivery",
"third_party_delivery"
],
"x-enum-varnames": [
"ShippingMethodPickup",
"ShippingMethodOwnDelivery",
"ShippingMethodThirdParty"
]
},
"domain.ShippingOption": {
"type": "object",
"properties": {
@ -2748,6 +3000,50 @@ const docTemplate = `{
}
}
},
"domain.ShippingSettings": {
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"free_shipping_threshold_cents": {
"type": "integer"
},
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
},
"max_radius_km": {
"type": "number"
},
"min_fee_cents": {
"type": "integer"
},
"pickup_active": {
"type": "boolean"
},
"pickup_address": {
"type": "string"
},
"pickup_hours": {
"type": "string"
},
"price_per_km_cents": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
"vendor_id": {
"type": "string"
}
}
},
"domain.TopProduct": {
"type": "object",
"properties": {
@ -2851,6 +3147,9 @@ const docTemplate = `{
"$ref": "#/definitions/domain.OrderItem"
}
},
"payment_method": {
"$ref": "#/definitions/domain.PaymentMethod"
},
"seller_id": {
"type": "string"
},
@ -2938,6 +3237,9 @@ const docTemplate = `{
"handler.loginRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
},
@ -3115,7 +3417,7 @@ const docTemplate = `{
}
}
},
"handler.shippingMethodRequest": {
"handler.shippingSettingsRequest": {
"type": "object",
"properties": {
"active": {
@ -3124,37 +3426,30 @@ const docTemplate = `{
"free_shipping_threshold_cents": {
"type": "integer"
},
"latitude": {
"description": "Store location for radius calc",
"type": "number"
},
"longitude": {
"type": "number"
},
"max_radius_km": {
"type": "number"
},
"min_fee_cents": {
"type": "integer"
},
"pickup_active": {
"type": "boolean"
},
"pickup_address": {
"type": "string"
},
"pickup_hours": {
"type": "string"
},
"preparation_minutes": {
"type": "integer"
},
"price_per_km_cents": {
"type": "integer"
},
"type": {
"type": "string"
}
}
},
"handler.shippingSettingsRequest": {
"type": "object",
"properties": {
"methods": {
"type": "array",
"items": {
"$ref": "#/definitions/handler.shippingMethodRequest"
}
}
}
},
@ -3255,24 +3550,17 @@ const docTemplate = `{
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "1.0",
Version: "",
Host: "",
BasePath: "/",
Schemes: []string{"http"},
Title: "SaveInMed Performance Core API",
Description: "API REST B2B para marketplace farmacêutico com split de pagamento e rastreabilidade.",
BasePath: "",
Schemes: []string{},
Title: "",
Description: "",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",

View file

@ -1,19 +1,121 @@
{
"schemes": [
"http"
],
"swagger": "2.0",
"info": {
"description": "API REST B2B para marketplace farmacêutico com split de pagamento e rastreabilidade.",
"title": "SaveInMed Performance Core API",
"contact": {
"name": "Engenharia SaveInMed",
"email": "devops@saveinmed.com"
},
"version": "1.0"
"contact": {}
},
"basePath": "/",
"paths": {
"/api/v1/admin/reviews": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Lista todas as avaliações (Admin)",
"parameters": [
{
"type": "integer",
"description": "Página",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Tamanho da página",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ReviewPage"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/admin/shipments": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Lista todos os envios (Admin)",
"parameters": [
{
"type": "integer",
"description": "Página",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Tamanho da página",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ShipmentPage"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/v1/auth/login": {
"post": {
"description": "Autentica usuário e retorna token JWT.",
@ -1502,6 +1604,60 @@
}
},
"/api/v1/reviews": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Lista todas as avaliações (Admin)",
"parameters": [
{
"type": "integer",
"description": "Página",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Tamanho da página",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ReviewPage"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"post": {
"security": [
{
@ -1549,6 +1705,60 @@
}
},
"/api/v1/shipments": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"Admin"
],
"summary": "Lista todos os envios (Admin)",
"parameters": [
{
"type": "integer",
"description": "Página",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "Tamanho da página",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/domain.ShipmentPage"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"post": {
"security": [
{
@ -1697,10 +1907,7 @@
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.ShippingMethod"
}
"$ref": "#/definitions/domain.ShippingSettings"
}
},
"400": {
@ -1766,10 +1973,7 @@
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.ShippingMethod"
}
"$ref": "#/definitions/domain.ShippingSettings"
}
},
"400": {
@ -2221,11 +2425,15 @@
"type": "string"
},
"created_at": {
"description": "Timestamps",
"type": "string"
},
"id": {
"type": "string"
},
"is_24_hours": {
"type": "boolean"
},
"is_verified": {
"type": "boolean"
},
@ -2239,6 +2447,14 @@
"longitude": {
"type": "number"
},
"operating_hours": {
"description": "e.g. \"Seg-Sex: 08:00-18:00, Sab: 08:00-12:00\"",
"type": "string"
},
"phone": {
"description": "Contact \u0026 Hours",
"type": "string"
},
"state": {
"type": "string"
},
@ -2308,6 +2524,9 @@
"$ref": "#/definitions/domain.OrderItem"
}
},
"payment_method": {
"$ref": "#/definitions/domain.PaymentMethod"
},
"seller_id": {
"type": "string"
},
@ -2366,6 +2585,19 @@
"OrderStatusDelivered"
]
},
"domain.PaymentMethod": {
"type": "string",
"enum": [
"pix",
"credit_card",
"debit_card"
],
"x-enum-varnames": [
"PaymentMethodPix",
"PaymentMethodCredit",
"PaymentMethodDebit"
]
},
"domain.PaymentPreference": {
"type": "object",
"properties": {
@ -2441,21 +2673,33 @@
"batch": {
"type": "string"
},
"category": {
"type": "string"
},
"created_at": {
"type": "string"
},
"description": {
"type": "string"
},
"ean_code": {
"type": "string"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "string"
},
"manufacturer": {
"type": "string"
},
"name": {
"type": "string"
},
"observations": {
"type": "string"
},
"price_cents": {
"type": "integer"
},
@ -2465,6 +2709,9 @@
"stock": {
"type": "integer"
},
"subcategory": {
"type": "string"
},
"updated_at": {
"type": "string"
}
@ -2516,6 +2763,9 @@
"batch": {
"type": "string"
},
"category": {
"type": "string"
},
"created_at": {
"type": "string"
},
@ -2525,15 +2775,24 @@
"distance_km": {
"type": "number"
},
"ean_code": {
"type": "string"
},
"expires_at": {
"type": "string"
},
"id": {
"type": "string"
},
"manufacturer": {
"type": "string"
},
"name": {
"type": "string"
},
"observations": {
"type": "string"
},
"price_cents": {
"type": "integer"
},
@ -2543,6 +2802,9 @@
"stock": {
"type": "integer"
},
"subcategory": {
"type": "string"
},
"tenant_city": {
"type": "string"
},
@ -2580,6 +2842,26 @@
}
}
},
"domain.ReviewPage": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"reviews": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.Review"
}
},
"total": {
"type": "integer"
}
}
},
"domain.SellerDashboard": {
"type": "object",
"properties": {
@ -2635,6 +2917,26 @@
}
}
},
"domain.ShipmentPage": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"shipments": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.Shipment"
}
},
"total": {
"type": "integer"
}
}
},
"domain.ShippingAddress": {
"type": "object",
"properties": {
@ -2667,63 +2969,6 @@
}
}
},
"domain.ShippingMethod": {
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"free_shipping_threshold_cents": {
"type": "integer"
},
"id": {
"type": "string"
},
"max_radius_km": {
"type": "number"
},
"min_fee_cents": {
"type": "integer"
},
"pickup_address": {
"type": "string"
},
"pickup_hours": {
"type": "string"
},
"preparation_minutes": {
"type": "integer"
},
"price_per_km_cents": {
"type": "integer"
},
"type": {
"$ref": "#/definitions/domain.ShippingMethodType"
},
"updated_at": {
"type": "string"
},
"vendor_id": {
"type": "string"
}
}
},
"domain.ShippingMethodType": {
"type": "string",
"enum": [
"pickup",
"own_delivery",
"third_party_delivery"
],
"x-enum-varnames": [
"ShippingMethodPickup",
"ShippingMethodOwnDelivery",
"ShippingMethodThirdParty"
]
},
"domain.ShippingOption": {
"type": "object",
"properties": {
@ -2744,6 +2989,50 @@
}
}
},
"domain.ShippingSettings": {
"type": "object",
"properties": {
"active": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"free_shipping_threshold_cents": {
"type": "integer"
},
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
},
"max_radius_km": {
"type": "number"
},
"min_fee_cents": {
"type": "integer"
},
"pickup_active": {
"type": "boolean"
},
"pickup_address": {
"type": "string"
},
"pickup_hours": {
"type": "string"
},
"price_per_km_cents": {
"type": "integer"
},
"updated_at": {
"type": "string"
},
"vendor_id": {
"type": "string"
}
}
},
"domain.TopProduct": {
"type": "object",
"properties": {
@ -2847,6 +3136,9 @@
"$ref": "#/definitions/domain.OrderItem"
}
},
"payment_method": {
"$ref": "#/definitions/domain.PaymentMethod"
},
"seller_id": {
"type": "string"
},
@ -2934,6 +3226,9 @@
"handler.loginRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string"
},
@ -3111,7 +3406,7 @@
}
}
},
"handler.shippingMethodRequest": {
"handler.shippingSettingsRequest": {
"type": "object",
"properties": {
"active": {
@ -3120,37 +3415,30 @@
"free_shipping_threshold_cents": {
"type": "integer"
},
"latitude": {
"description": "Store location for radius calc",
"type": "number"
},
"longitude": {
"type": "number"
},
"max_radius_km": {
"type": "number"
},
"min_fee_cents": {
"type": "integer"
},
"pickup_active": {
"type": "boolean"
},
"pickup_address": {
"type": "string"
},
"pickup_hours": {
"type": "string"
},
"preparation_minutes": {
"type": "integer"
},
"price_per_km_cents": {
"type": "integer"
},
"type": {
"type": "string"
}
}
},
"handler.shippingSettingsRequest": {
"type": "object",
"properties": {
"methods": {
"type": "array",
"items": {
"$ref": "#/definitions/handler.shippingMethodRequest"
}
}
}
},
@ -3251,12 +3539,5 @@
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
}

View file

@ -1,4 +1,3 @@
basePath: /
definitions:
domain.AdminDashboard:
properties:
@ -59,9 +58,12 @@ definitions:
corporate_name:
type: string
created_at:
description: Timestamps
type: string
id:
type: string
is_24_hours:
type: boolean
is_verified:
type: boolean
latitude:
@ -71,6 +73,12 @@ definitions:
type: string
longitude:
type: number
operating_hours:
description: 'e.g. "Seg-Sex: 08:00-18:00, Sab: 08:00-12:00"'
type: string
phone:
description: Contact & Hours
type: string
state:
type: string
updated_at:
@ -116,6 +124,8 @@ definitions:
items:
$ref: '#/definitions/domain.OrderItem'
type: array
payment_method:
$ref: '#/definitions/domain.PaymentMethod'
seller_id:
type: string
shipping:
@ -156,6 +166,16 @@ definitions:
- OrderStatusPaid
- OrderStatusInvoiced
- OrderStatusDelivered
domain.PaymentMethod:
enum:
- pix
- credit_card
- debit_card
type: string
x-enum-varnames:
- PaymentMethodPix
- PaymentMethodCredit
- PaymentMethodDebit
domain.PaymentPreference:
properties:
commission_pct:
@ -205,22 +225,32 @@ definitions:
properties:
batch:
type: string
category:
type: string
created_at:
type: string
description:
type: string
ean_code:
type: string
expires_at:
type: string
id:
type: string
manufacturer:
type: string
name:
type: string
observations:
type: string
price_cents:
type: integer
seller_id:
type: string
stock:
type: integer
subcategory:
type: string
updated_at:
type: string
type: object
@ -254,24 +284,34 @@ definitions:
properties:
batch:
type: string
category:
type: string
created_at:
type: string
description:
type: string
distance_km:
type: number
ean_code:
type: string
expires_at:
type: string
id:
type: string
manufacturer:
type: string
name:
type: string
observations:
type: string
price_cents:
type: integer
seller_id:
type: string
stock:
type: integer
subcategory:
type: string
tenant_city:
type: string
tenant_state:
@ -296,6 +336,19 @@ definitions:
seller_id:
type: string
type: object
domain.ReviewPage:
properties:
page:
type: integer
page_size:
type: integer
reviews:
items:
$ref: '#/definitions/domain.Review'
type: array
total:
type: integer
type: object
domain.SellerDashboard:
properties:
low_stock_alerts:
@ -332,6 +385,19 @@ definitions:
updated_at:
type: string
type: object
domain.ShipmentPage:
properties:
page:
type: integer
page_size:
type: integer
shipments:
items:
$ref: '#/definitions/domain.Shipment'
type: array
total:
type: integer
type: object
domain.ShippingAddress:
properties:
city:
@ -353,45 +419,6 @@ definitions:
zip_code:
type: string
type: object
domain.ShippingMethod:
properties:
active:
type: boolean
created_at:
type: string
free_shipping_threshold_cents:
type: integer
id:
type: string
max_radius_km:
type: number
min_fee_cents:
type: integer
pickup_address:
type: string
pickup_hours:
type: string
preparation_minutes:
type: integer
price_per_km_cents:
type: integer
type:
$ref: '#/definitions/domain.ShippingMethodType'
updated_at:
type: string
vendor_id:
type: string
type: object
domain.ShippingMethodType:
enum:
- pickup
- own_delivery
- third_party_delivery
type: string
x-enum-varnames:
- ShippingMethodPickup
- ShippingMethodOwnDelivery
- ShippingMethodThirdParty
domain.ShippingOption:
properties:
description:
@ -405,6 +432,35 @@ definitions:
value_cents:
type: integer
type: object
domain.ShippingSettings:
properties:
active:
type: boolean
created_at:
type: string
free_shipping_threshold_cents:
type: integer
latitude:
type: number
longitude:
type: number
max_radius_km:
type: number
min_fee_cents:
type: integer
pickup_active:
type: boolean
pickup_address:
type: string
pickup_hours:
type: string
price_per_km_cents:
type: integer
updated_at:
type: string
vendor_id:
type: string
type: object
domain.TopProduct:
properties:
name:
@ -472,6 +528,8 @@ definitions:
items:
$ref: '#/definitions/domain.OrderItem'
type: array
payment_method:
$ref: '#/definitions/domain.PaymentMethod'
seller_id:
type: string
shipping:
@ -528,6 +586,8 @@ definitions:
type: object
handler.loginRequest:
properties:
email:
type: string
password:
type: string
username:
@ -643,33 +703,29 @@ definitions:
vendor_id:
type: string
type: object
handler.shippingMethodRequest:
handler.shippingSettingsRequest:
properties:
active:
type: boolean
free_shipping_threshold_cents:
type: integer
latitude:
description: Store location for radius calc
type: number
longitude:
type: number
max_radius_km:
type: number
min_fee_cents:
type: integer
pickup_active:
type: boolean
pickup_address:
type: string
pickup_hours:
type: string
preparation_minutes:
type: integer
price_per_km_cents:
type: integer
type:
type: string
type: object
handler.shippingSettingsRequest:
properties:
methods:
items:
$ref: '#/definitions/handler.shippingMethodRequest'
type: array
type: object
handler.updateCompanyRequest:
properties:
@ -735,14 +791,78 @@ definitions:
type: string
type: object
info:
contact:
email: devops@saveinmed.com
name: Engenharia SaveInMed
description: API REST B2B para marketplace farmacêutico com split de pagamento e
rastreabilidade.
title: SaveInMed Performance Core API
version: "1.0"
contact: {}
paths:
/api/v1/admin/reviews:
get:
parameters:
- description: Página
in: query
name: page
type: integer
- description: Tamanho da página
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.ReviewPage'
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: Lista todas as avaliações (Admin)
tags:
- Admin
/api/v1/admin/shipments:
get:
parameters:
- description: Página
in: query
name: page
type: integer
- description: Tamanho da página
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.ShipmentPage'
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: Lista todos os envios (Admin)
tags:
- Admin
/api/v1/auth/login:
post:
consumes:
@ -1697,6 +1817,40 @@ paths:
tags:
- Produtos
/api/v1/reviews:
get:
parameters:
- description: Página
in: query
name: page
type: integer
- description: Tamanho da página
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.ReviewPage'
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: Lista todas as avaliações (Admin)
tags:
- Admin
post:
consumes:
- application/json
@ -1726,6 +1880,40 @@ paths:
tags:
- Avaliações
/api/v1/shipments:
get:
parameters:
- description: Página
in: query
name: page
type: integer
- description: Tamanho da página
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/domain.ShipmentPage'
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: Lista todos os envios (Admin)
tags:
- Admin
post:
consumes:
- application/json
@ -1820,9 +2008,7 @@ paths:
"200":
description: OK
schema:
items:
$ref: '#/definitions/domain.ShippingMethod'
type: array
$ref: '#/definitions/domain.ShippingSettings'
"400":
description: Bad Request
schema:
@ -1866,9 +2052,7 @@ paths:
"200":
description: OK
schema:
items:
$ref: '#/definitions/domain.ShippingMethod'
type: array
$ref: '#/definitions/domain.ShippingSettings'
"400":
description: Bad Request
schema:
@ -2099,11 +2283,4 @@ paths:
summary: Atualizar usuário
tags:
- Usuários
schemes:
- http
securityDefinitions:
BearerAuth:
in: header
name: Authorization
type: apiKey
swagger: "2.0"

View file

@ -184,15 +184,16 @@ type InventoryAdjustment struct {
// Order captures the status lifecycle and payment intent.
type Order struct {
ID uuid.UUID `db:"id" json:"id"`
BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"`
SellerID uuid.UUID `db:"seller_id" json:"seller_id"`
Status OrderStatus `db:"status" json:"status"`
TotalCents int64 `db:"total_cents" json:"total_cents"`
Items []OrderItem `json:"items"`
Shipping ShippingAddress `json:"shipping"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"`
SellerID uuid.UUID `db:"seller_id" json:"seller_id"`
Status OrderStatus `db:"status" json:"status"`
TotalCents int64 `db:"total_cents" json:"total_cents"`
PaymentMethod PaymentMethod `db:"payment_method" json:"payment_method"`
Items []OrderItem `json:"items"`
Shipping ShippingAddress `json:"shipping"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// OrderItem stores SKU-level batch tracking.
@ -316,6 +317,15 @@ const (
OrderStatusDelivered OrderStatus = "Entregue"
)
// PaymentMethod enumerates supported payment types.
type PaymentMethod string
const (
PaymentMethodPix PaymentMethod = "pix"
PaymentMethodCredit PaymentMethod = "credit_card"
PaymentMethodDebit PaymentMethod = "debit_card"
)
// CartItem stores buyer selections with unit pricing.
type CartItem struct {
ID uuid.UUID `db:"id" json:"id"`

View file

@ -155,10 +155,11 @@ type updateProductRequest struct {
}
type createOrderRequest struct {
BuyerID uuid.UUID `json:"buyer_id"`
SellerID uuid.UUID `json:"seller_id"`
Items []domain.OrderItem `json:"items"`
Shipping domain.ShippingAddress `json:"shipping"`
BuyerID uuid.UUID `json:"buyer_id"`
SellerID uuid.UUID `json:"seller_id"`
Items []domain.OrderItem `json:"items"`
Shipping domain.ShippingAddress `json:"shipping"`
PaymentMethod domain.PaymentMethod `json:"payment_method"`
}
type createShipmentRequest struct {

View file

@ -38,7 +38,7 @@ func NewMockRepository() *MockRepository {
// Company methods
func (m *MockRepository) CreateCompany(ctx context.Context, company *domain.Company) error {
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
company.ID = id
company.CreatedAt = time.Now()
company.UpdatedAt = time.Now()
@ -81,7 +81,7 @@ func (m *MockRepository) DeleteCompany(ctx context.Context, id uuid.UUID) error
// Product methods
func (m *MockRepository) CreateProduct(ctx context.Context, product *domain.Product) error {
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
product.ID = id
product.CreatedAt = time.Now()
product.UpdatedAt = time.Now()
@ -140,7 +140,7 @@ func (m *MockRepository) SearchProducts(ctx context.Context, filter domain.Produ
}
func (m *MockRepository) CreateOrder(ctx context.Context, order *domain.Order) error {
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
order.ID = id
m.orders = append(m.orders, *order)
return nil
@ -176,7 +176,7 @@ func (m *MockRepository) GetShipmentByOrderID(ctx context.Context, orderID uuid.
}
func (m *MockRepository) CreateUser(ctx context.Context, user *domain.User) error {
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
user.ID = id
m.users = append(m.users, *user)
return nil
@ -365,7 +365,7 @@ func TestCreateCompany(t *testing.T) {
func TestCreateProduct(t *testing.T) {
h := newTestHandler()
sellerID, _ := uuid.NewV4()
sellerID, _ := uuid.NewV7()
payload := `{"seller_id":"` + sellerID.String() + `","name":"Aspirin","description":"Pain relief","batch":"BATCH-001","expires_at":"2025-12-31T00:00:00Z","price_cents":1000,"stock":100}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/products", bytes.NewReader([]byte(payload)))
req.Header.Set("Content-Type", "application/json")
@ -401,7 +401,7 @@ func TestAdminLogin_Success(t *testing.T) {
h := New(svc)
// Create admin user through service (which hashes password)
companyID, _ := uuid.NewV4()
companyID, _ := uuid.NewV7()
user := &domain.User{
CompanyID: companyID,
Role: "admin",
@ -445,7 +445,7 @@ func TestAdminLogin_WrongPassword(t *testing.T) {
h := New(svc)
// Create admin user
companyID, _ := uuid.NewV4()
companyID, _ := uuid.NewV7()
user := &domain.User{
CompanyID: companyID,
Role: "admin",
@ -519,7 +519,7 @@ func TestRegister_MissingCompany(t *testing.T) {
func TestGetCompany_NotFound(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
req := httptest.NewRequest(http.MethodGet, "/api/v1/companies/"+id.String(), nil)
rec := httptest.NewRecorder()
h.GetCompany(rec, req)
@ -540,7 +540,7 @@ func TestGetCompany_InvalidUUID(t *testing.T) {
func TestUpdateCompany_InvalidJSON(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
req := httptest.NewRequest(http.MethodPatch, "/api/v1/companies/"+id.String(), bytes.NewReader([]byte("{")))
rec := httptest.NewRecorder()
h.UpdateCompany(rec, req)
@ -573,7 +573,7 @@ func TestVerifyCompany_InvalidPath(t *testing.T) {
func TestGetProduct_NotFound(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
req := httptest.NewRequest(http.MethodGet, "/api/v1/products/"+id.String(), nil)
rec := httptest.NewRecorder()
h.GetProduct(rec, req)
@ -584,7 +584,7 @@ func TestGetProduct_NotFound(t *testing.T) {
func TestUpdateProduct_InvalidJSON(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
req := httptest.NewRequest(http.MethodPatch, "/api/v1/products/"+id.String(), bytes.NewReader([]byte("{")))
rec := httptest.NewRecorder()
h.UpdateProduct(rec, req)
@ -637,7 +637,7 @@ func TestAdjustInventory_InvalidJSON(t *testing.T) {
func TestAdjustInventory_ZeroDelta(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
payload := `{"product_id":"` + id.String() + `","delta":0,"reason":"test"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/inventory/adjust", bytes.NewReader([]byte(payload)))
rec := httptest.NewRecorder()
@ -661,7 +661,7 @@ func TestCreateOrder_InvalidJSON(t *testing.T) {
func TestGetOrder_NotFound(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
req := httptest.NewRequest(http.MethodGet, "/api/v1/orders/"+id.String(), nil)
rec := httptest.NewRecorder()
h.GetOrder(rec, req)
@ -672,7 +672,7 @@ func TestGetOrder_NotFound(t *testing.T) {
func TestUpdateOrderStatus_InvalidStatus(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
payload := `{"status":"invalid_status"}`
req := httptest.NewRequest(http.MethodPatch, "/api/v1/orders/"+id.String()+"/status", bytes.NewReader([]byte(payload)))
rec := httptest.NewRecorder()
@ -783,7 +783,7 @@ func TestListUsers_Success(t *testing.T) {
func TestGetUser_NotFound(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/"+id.String(), nil)
req.Header.Set("X-User-Role", "Admin")
rec := httptest.NewRecorder()
@ -806,7 +806,7 @@ func TestCreateUser_InvalidJSON(t *testing.T) {
func TestUpdateUser_InvalidJSON(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
req := httptest.NewRequest(http.MethodPut, "/api/v1/users/"+id.String(), bytes.NewReader([]byte("{")))
req.Header.Set("X-User-Role", "Admin")
rec := httptest.NewRecorder()
@ -851,7 +851,7 @@ func TestAddToCart_InvalidJSON(t *testing.T) {
func TestDeleteCartItem_NoContext(t *testing.T) {
h := newTestHandler()
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/cart/"+id.String(), nil)
rec := httptest.NewRecorder()
h.DeleteCartItem(rec, req)

View file

@ -23,10 +23,11 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
}
order := &domain.Order{
BuyerID: req.BuyerID,
SellerID: req.SellerID,
Items: req.Items,
Shipping: req.Shipping,
BuyerID: req.BuyerID,
SellerID: req.SellerID,
Items: req.Items,
Shipping: req.Shipping,
PaymentMethod: req.PaymentMethod,
}
var total int64

View file

@ -113,8 +113,8 @@ func createTestToken(secret string, userID uuid.UUID, role string, companyID *uu
func TestRequireAuthValidToken(t *testing.T) {
secret := "test-secret"
userID, _ := uuid.NewV4()
companyID, _ := uuid.NewV4()
userID, _ := uuid.NewV7()
companyID, _ := uuid.NewV7()
tokenStr := createTestToken(secret, userID, "Admin", &companyID)
var receivedClaims Claims
@ -172,7 +172,7 @@ func TestRequireAuthInvalidToken(t *testing.T) {
}
func TestRequireAuthWrongSecret(t *testing.T) {
userID, _ := uuid.NewV4()
userID, _ := uuid.NewV7()
tokenStr := createTestToken("correct-secret", userID, "User", nil)
handler := RequireAuth([]byte("wrong-secret"))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -192,7 +192,7 @@ func TestRequireAuthWrongSecret(t *testing.T) {
func TestRequireAuthRoleRestriction(t *testing.T) {
secret := "secret"
userID, _ := uuid.NewV4()
userID, _ := uuid.NewV7()
tokenStr := createTestToken(secret, userID, "User", nil)
handler := RequireAuth([]byte(secret), "Admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -212,7 +212,7 @@ func TestRequireAuthRoleRestriction(t *testing.T) {
func TestRequireAuthRoleAllowed(t *testing.T) {
secret := "secret"
userID, _ := uuid.NewV4()
userID, _ := uuid.NewV7()
tokenStr := createTestToken(secret, userID, "Admin", nil)
handler := RequireAuth([]byte(secret), "Admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -232,7 +232,7 @@ func TestRequireAuthRoleAllowed(t *testing.T) {
func TestGetClaimsFromContext(t *testing.T) {
claims := Claims{
UserID: uuid.Must(uuid.NewV4()),
UserID: uuid.Must(uuid.NewV7()),
Role: "Admin",
}
ctx := context.WithValue(context.Background(), claimsKey, claims)
@ -345,7 +345,7 @@ func TestCORSLegacyWrapper(t *testing.T) {
func TestRequireAuthExpiredToken(t *testing.T) {
secret := "test-secret"
userID, _ := uuid.NewV4()
userID, _ := uuid.NewV7()
// Create an expired token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{

View file

@ -24,9 +24,9 @@ func TestCreatePreference(t *testing.T) {
gateway := NewMercadoPagoGateway("https://api.mercadopago.com", 2.5)
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
BuyerID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
BuyerID: uuid.Must(uuid.NewV7()),
SellerID: uuid.Must(uuid.NewV7()),
TotalCents: 10000, // R$100
}
@ -83,7 +83,7 @@ func TestCreatePreferenceWithDifferentCommissions(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
gateway := NewMercadoPagoGateway("https://test.com", tc.commission)
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
TotalCents: tc.totalCents,
}
@ -106,7 +106,7 @@ func TestCreatePreferenceWithCancelledContext(t *testing.T) {
gateway := NewMercadoPagoGateway("https://api.mercadopago.com", 2.5)
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
TotalCents: 10000,
}
@ -123,7 +123,7 @@ func TestCreatePreferenceWithTimeout(t *testing.T) {
gateway := NewMercadoPagoGateway("https://api.mercadopago.com", 2.5)
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
TotalCents: 10000,
}
@ -144,7 +144,7 @@ func TestCreatePreferenceWithZeroTotal(t *testing.T) {
gateway := NewMercadoPagoGateway("https://api.mercadopago.com", 2.5)
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
TotalCents: 0,
}

View file

@ -459,9 +459,9 @@ func (r *Repository) CreateOrder(ctx context.Context, order *domain.Order) error
return err
}
orderQuery := `INSERT INTO orders (id, buyer_id, seller_id, status, total_cents, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`
if _, err := tx.ExecContext(ctx, orderQuery, order.ID, order.BuyerID, order.SellerID, order.Status, order.TotalCents, order.Shipping.RecipientName, order.Shipping.Street, order.Shipping.Number, order.Shipping.Complement, order.Shipping.District, order.Shipping.City, order.Shipping.State, order.Shipping.ZipCode, order.Shipping.Country, order.CreatedAt, order.UpdatedAt); err != nil {
orderQuery := `INSERT INTO orders (id, buyer_id, seller_id, status, total_cents, payment_method, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`
if _, err := tx.ExecContext(ctx, orderQuery, order.ID, order.BuyerID, order.SellerID, order.Status, order.TotalCents, order.PaymentMethod, order.Shipping.RecipientName, order.Shipping.Street, order.Shipping.Number, order.Shipping.Complement, order.Shipping.District, order.Shipping.City, order.Shipping.State, order.Shipping.ZipCode, order.Shipping.Country, order.CreatedAt, order.UpdatedAt); err != nil {
_ = tx.Rollback()
return err
}
@ -528,25 +528,26 @@ func (r *Repository) ListOrders(ctx context.Context, filter domain.OrderFilter)
filter.Limit = 20
}
args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf(`SELECT id, buyer_id, seller_id, status, total_cents, COALESCE(shipping_recipient_name, '') as shipping_recipient_name, COALESCE(shipping_street, '') as shipping_street, COALESCE(shipping_number, '') as shipping_number, COALESCE(shipping_complement, '') as shipping_complement, COALESCE(shipping_district, '') as shipping_district, COALESCE(shipping_city, '') as shipping_city, COALESCE(shipping_state, '') as shipping_state, COALESCE(shipping_zip_code, '') as shipping_zip_code, COALESCE(shipping_country, '') as shipping_country, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, baseQuery, where, len(args)-1, len(args))
listQuery := fmt.Sprintf(`SELECT id, buyer_id, seller_id, status, total_cents, payment_method, COALESCE(shipping_recipient_name, '') as shipping_recipient_name, COALESCE(shipping_street, '') as shipping_street, COALESCE(shipping_number, '') as shipping_number, COALESCE(shipping_complement, '') as shipping_complement, COALESCE(shipping_district, '') as shipping_district, COALESCE(shipping_city, '') as shipping_city, COALESCE(shipping_state, '') as shipping_state, COALESCE(shipping_zip_code, '') as shipping_zip_code, COALESCE(shipping_country, '') as shipping_country, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, baseQuery, where, len(args)-1, len(args))
var rows []struct {
ID uuid.UUID `db:"id"`
BuyerID uuid.UUID `db:"buyer_id"`
SellerID uuid.UUID `db:"seller_id"`
Status domain.OrderStatus `db:"status"`
TotalCents int64 `db:"total_cents"`
ShippingRecipientName string `db:"shipping_recipient_name"`
ShippingStreet string `db:"shipping_street"`
ShippingNumber string `db:"shipping_number"`
ShippingComplement string `db:"shipping_complement"`
ShippingDistrict string `db:"shipping_district"`
ShippingCity string `db:"shipping_city"`
ShippingState string `db:"shipping_state"`
ShippingZipCode string `db:"shipping_zip_code"`
ShippingCountry string `db:"shipping_country"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID uuid.UUID `db:"id"`
BuyerID uuid.UUID `db:"buyer_id"`
SellerID uuid.UUID `db:"seller_id"`
Status domain.OrderStatus `db:"status"`
TotalCents int64 `db:"total_cents"`
PaymentMethod domain.PaymentMethod `db:"payment_method"`
ShippingRecipientName string `db:"shipping_recipient_name"`
ShippingStreet string `db:"shipping_street"`
ShippingNumber string `db:"shipping_number"`
ShippingComplement string `db:"shipping_complement"`
ShippingDistrict string `db:"shipping_district"`
ShippingCity string `db:"shipping_city"`
ShippingState string `db:"shipping_state"`
ShippingZipCode string `db:"shipping_zip_code"`
ShippingCountry string `db:"shipping_country"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
if err := r.db.SelectContext(ctx, &rows, listQuery, args...); err != nil {
@ -561,12 +562,13 @@ func (r *Repository) ListOrders(ctx context.Context, filter domain.OrderFilter)
return nil, 0, err
}
orders = append(orders, domain.Order{
ID: row.ID,
BuyerID: row.BuyerID,
SellerID: row.SellerID,
Status: row.Status,
TotalCents: row.TotalCents,
Items: items,
ID: row.ID,
BuyerID: row.BuyerID,
SellerID: row.SellerID,
Status: row.Status,
TotalCents: row.TotalCents,
PaymentMethod: row.PaymentMethod,
Items: items,
Shipping: domain.ShippingAddress{
RecipientName: row.ShippingRecipientName,
Street: row.ShippingStreet,
@ -587,24 +589,25 @@ func (r *Repository) ListOrders(ctx context.Context, filter domain.OrderFilter)
func (r *Repository) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
var row struct {
ID uuid.UUID `db:"id"`
BuyerID uuid.UUID `db:"buyer_id"`
SellerID uuid.UUID `db:"seller_id"`
Status domain.OrderStatus `db:"status"`
TotalCents int64 `db:"total_cents"`
ShippingRecipientName string `db:"shipping_recipient_name"`
ShippingStreet string `db:"shipping_street"`
ShippingNumber string `db:"shipping_number"`
ShippingComplement string `db:"shipping_complement"`
ShippingDistrict string `db:"shipping_district"`
ShippingCity string `db:"shipping_city"`
ShippingState string `db:"shipping_state"`
ShippingZipCode string `db:"shipping_zip_code"`
ShippingCountry string `db:"shipping_country"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID uuid.UUID `db:"id"`
BuyerID uuid.UUID `db:"buyer_id"`
SellerID uuid.UUID `db:"seller_id"`
Status domain.OrderStatus `db:"status"`
TotalCents int64 `db:"total_cents"`
PaymentMethod domain.PaymentMethod `db:"payment_method"`
ShippingRecipientName string `db:"shipping_recipient_name"`
ShippingStreet string `db:"shipping_street"`
ShippingNumber string `db:"shipping_number"`
ShippingComplement string `db:"shipping_complement"`
ShippingDistrict string `db:"shipping_district"`
ShippingCity string `db:"shipping_city"`
ShippingState string `db:"shipping_state"`
ShippingZipCode string `db:"shipping_zip_code"`
ShippingCountry string `db:"shipping_country"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
orderQuery := `SELECT id, buyer_id, seller_id, status, total_cents, COALESCE(shipping_recipient_name, '') as shipping_recipient_name, COALESCE(shipping_street, '') as shipping_street, COALESCE(shipping_number, '') as shipping_number, COALESCE(shipping_complement, '') as shipping_complement, COALESCE(shipping_district, '') as shipping_district, COALESCE(shipping_city, '') as shipping_city, COALESCE(shipping_state, '') as shipping_state, COALESCE(shipping_zip_code, '') as shipping_zip_code, COALESCE(shipping_country, '') as shipping_country, created_at, updated_at FROM orders WHERE id = $1`
orderQuery := `SELECT id, buyer_id, seller_id, status, total_cents, payment_method, COALESCE(shipping_recipient_name, '') as shipping_recipient_name, COALESCE(shipping_street, '') as shipping_street, COALESCE(shipping_number, '') as shipping_number, COALESCE(shipping_complement, '') as shipping_complement, COALESCE(shipping_district, '') as shipping_district, COALESCE(shipping_city, '') as shipping_city, COALESCE(shipping_state, '') as shipping_state, COALESCE(shipping_zip_code, '') as shipping_zip_code, COALESCE(shipping_country, '') as shipping_country, created_at, updated_at FROM orders WHERE id = $1`
if err := r.db.GetContext(ctx, &row, orderQuery, id); err != nil {
return nil, err
}
@ -615,12 +618,13 @@ func (r *Repository) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order,
return nil, err
}
order := &domain.Order{
ID: row.ID,
BuyerID: row.BuyerID,
SellerID: row.SellerID,
Status: row.Status,
TotalCents: row.TotalCents,
Items: items,
ID: row.ID,
BuyerID: row.BuyerID,
SellerID: row.SellerID,
Status: row.Status,
TotalCents: row.TotalCents,
PaymentMethod: row.PaymentMethod,
Items: items,
Shipping: domain.ShippingAddress{
RecipientName: row.ShippingRecipientName,
Street: row.ShippingStreet,

View file

@ -28,7 +28,7 @@ func TestCreateCompany(t *testing.T) {
defer repo.db.Close()
company := &domain.Company{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
CNPJ: "12345678901234",
CorporateName: "Test Pharmacy",
Category: "farmacia",
@ -72,7 +72,7 @@ func TestGetCompany(t *testing.T) {
repo, mock := newMockRepo(t)
defer repo.db.Close()
id := uuid.Must(uuid.NewV4())
id := uuid.Must(uuid.NewV7())
rows := sqlmock.NewRows([]string{"id", "cnpj", "corporate_name", "category", "license_number", "is_verified", "latitude", "longitude", "city", "state", "created_at", "updated_at"}).
AddRow(id, "123", "Test", "farmacia", "123", false, 0.0, 0.0, "City", "ST", time.Now(), time.Now())
@ -97,8 +97,8 @@ func TestCreateProduct(t *testing.T) {
defer repo.db.Close()
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
SellerID: uuid.Must(uuid.NewV7()),
Name: "Test Product",
Description: "Desc",
Batch: "B1",
@ -130,7 +130,7 @@ func TestListProducts(t *testing.T) {
repo, mock := newMockRepo(t)
defer repo.db.Close()
rows := sqlmock.NewRows([]string{"id", "name"}).AddRow(uuid.Must(uuid.NewV4()), "P1")
rows := sqlmock.NewRows([]string{"id", "name"}).AddRow(uuid.Must(uuid.NewV7()), "P1")
// We expect two queries: count and select list
mock.ExpectQuery(`SELECT count\(\*\) FROM products`).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))

View file

@ -379,7 +379,7 @@ func TestGetCompany(t *testing.T) {
ctx := context.Background()
company := &domain.Company{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
Category: "farmacia",
CNPJ: "12345678901234",
CorporateName: "Test Pharmacy",
@ -400,7 +400,7 @@ func TestUpdateCompany(t *testing.T) {
ctx := context.Background()
company := &domain.Company{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
Category: "farmacia",
CNPJ: "12345678901234",
CorporateName: "Test Pharmacy",
@ -423,7 +423,7 @@ func TestDeleteCompany(t *testing.T) {
ctx := context.Background()
company := &domain.Company{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
CorporateName: "Test Pharmacy",
}
repo.companies = append(repo.companies, *company)
@ -443,7 +443,7 @@ func TestVerifyCompany(t *testing.T) {
ctx := context.Background()
company := &domain.Company{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
Category: "farmacia",
CNPJ: "12345678901234",
CorporateName: "Test Pharmacy",
@ -468,7 +468,7 @@ func TestRegisterProduct(t *testing.T) {
ctx := context.Background()
product := &domain.Product{
SellerID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV7()),
Name: "Test Product",
Description: "A test product",
Batch: "BATCH-001",
@ -506,7 +506,7 @@ func TestGetProduct(t *testing.T) {
ctx := context.Background()
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
Name: "Test Product",
}
repo.products = append(repo.products, *product)
@ -525,7 +525,7 @@ func TestUpdateProduct(t *testing.T) {
ctx := context.Background()
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
Name: "Test Product",
}
repo.products = append(repo.products, *product)
@ -546,7 +546,7 @@ func TestDeleteProduct(t *testing.T) {
ctx := context.Background()
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
Name: "Test Product",
}
repo.products = append(repo.products, *product)
@ -592,7 +592,7 @@ func TestListInventory(t *testing.T) {
func TestAdjustInventory(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
productID := uuid.Must(uuid.NewV4())
productID := uuid.Must(uuid.NewV7())
item, err := svc.AdjustInventory(ctx, productID, 10, "Restock")
if err != nil {
@ -611,8 +611,8 @@ func TestCreateOrder(t *testing.T) {
ctx := context.Background()
order := &domain.Order{
BuyerID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV4()),
BuyerID: uuid.Must(uuid.NewV7()),
SellerID: uuid.Must(uuid.NewV7()),
TotalCents: 10000,
}
@ -634,9 +634,9 @@ func TestUpdateOrderStatus(t *testing.T) {
ctx := context.Background()
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
BuyerID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
BuyerID: uuid.Must(uuid.NewV7()),
SellerID: uuid.Must(uuid.NewV7()),
Status: domain.OrderStatusPending,
TotalCents: 10000,
}
@ -655,7 +655,7 @@ func TestCreateUser(t *testing.T) {
ctx := context.Background()
user := &domain.User{
CompanyID: uuid.Must(uuid.NewV4()),
CompanyID: uuid.Must(uuid.NewV7()),
Role: "admin",
Name: "Test User",
Email: "test@example.com",
@ -722,7 +722,7 @@ func TestAuthenticate(t *testing.T) {
// First create a user
user := &domain.User{
CompanyID: uuid.Must(uuid.NewV4()),
CompanyID: uuid.Must(uuid.NewV7()),
Role: "admin",
Name: "Test User",
Username: "authuser",
@ -755,7 +755,7 @@ func TestAuthenticateInvalidPassword(t *testing.T) {
ctx := context.Background()
user := &domain.User{
CompanyID: uuid.Must(uuid.NewV4()),
CompanyID: uuid.Must(uuid.NewV7()),
Role: "admin",
Name: "Test User",
Username: "failuser",
@ -776,10 +776,10 @@ func TestAddItemToCart(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
buyerID := uuid.Must(uuid.NewV4())
buyerID := uuid.Must(uuid.NewV7())
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
SellerID: uuid.Must(uuid.NewV7()),
Name: "Test Product",
PriceCents: 1000,
Stock: 100,
@ -802,8 +802,8 @@ func TestAddItemToCartInvalidQuantity(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
buyerID := uuid.Must(uuid.NewV4())
productID := uuid.Must(uuid.NewV4())
buyerID := uuid.Must(uuid.NewV7())
productID := uuid.Must(uuid.NewV7())
_, err := svc.AddItemToCart(ctx, buyerID, productID, 0)
if err == nil {
@ -815,10 +815,10 @@ func TestCartB2BDiscount(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
buyerID := uuid.Must(uuid.NewV4())
buyerID := uuid.Must(uuid.NewV7())
product := &domain.Product{
ID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
SellerID: uuid.Must(uuid.NewV7()),
Name: "Expensive Product",
PriceCents: 50000, // R$500 per unit
Stock: 1000,
@ -847,11 +847,11 @@ func TestCreateReview(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
buyerID := uuid.Must(uuid.NewV4())
sellerID := uuid.Must(uuid.NewV4())
buyerID := uuid.Must(uuid.NewV7())
sellerID := uuid.Must(uuid.NewV7())
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
BuyerID: buyerID,
SellerID: sellerID,
Status: domain.OrderStatusDelivered,
@ -873,7 +873,7 @@ func TestCreateReviewInvalidRating(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
_, err := svc.CreateReview(ctx, uuid.Must(uuid.NewV4()), uuid.Must(uuid.NewV4()), 6, "Invalid")
_, err := svc.CreateReview(ctx, uuid.Must(uuid.NewV7()), uuid.Must(uuid.NewV7()), 6, "Invalid")
if err == nil {
t.Error("expected error for invalid rating")
}
@ -883,9 +883,9 @@ func TestCreateReviewNotDelivered(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
buyerID := uuid.Must(uuid.NewV4())
buyerID := uuid.Must(uuid.NewV7())
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
BuyerID: buyerID,
Status: domain.OrderStatusPending, // Not delivered
TotalCents: 10000,
@ -904,7 +904,7 @@ func TestGetSellerDashboard(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
sellerID := uuid.Must(uuid.NewV4())
sellerID := uuid.Must(uuid.NewV7())
dashboard, err := svc.GetSellerDashboard(ctx, sellerID)
if err != nil {
t.Fatalf("failed to get seller dashboard: %v", err)
@ -936,9 +936,9 @@ func TestCreatePaymentPreference(t *testing.T) {
ctx := context.Background()
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
BuyerID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
BuyerID: uuid.Must(uuid.NewV7()),
SellerID: uuid.Must(uuid.NewV7()),
TotalCents: 10000,
}
repo.orders = append(repo.orders, *order)
@ -961,9 +961,9 @@ func TestHandlePaymentWebhook(t *testing.T) {
ctx := context.Background()
order := &domain.Order{
ID: uuid.Must(uuid.NewV4()),
BuyerID: uuid.Must(uuid.NewV4()),
SellerID: uuid.Must(uuid.NewV4()),
ID: uuid.Must(uuid.NewV7()),
BuyerID: uuid.Must(uuid.NewV7()),
SellerID: uuid.Must(uuid.NewV7()),
Status: domain.OrderStatusPending,
TotalCents: 10000,
}

View file

@ -1,11 +1,13 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Shell } from '../layouts/Shell'
import { useCartStore, selectGroupedCart, selectCartSummary } from '../stores/cartStore'
import { useAuth } from '../context/AuthContext'
import { ordersService, CreateOrderRequest } from '../services/ordersService'
import { shippingService } from '../services/shippingService'
import { apiClient } from '../services/apiClient'
import { formatCurrency } from '../utils/format'
import { ArrowLeft, CheckCircle2, Truck } from 'lucide-react'
import { ArrowLeft, CheckCircle2, Truck, Store } from 'lucide-react'
export function CheckoutPage() {
const navigate = useNavigate()
@ -15,6 +17,10 @@ export function CheckoutPage() {
const clearAll = useCartStore((state) => state.clearAll)
const [loading, setLoading] = useState(false)
// Shipping options state
const [shippingOptions, setShippingOptions] = useState<Record<string, { delivery: boolean, pickup: boolean, pickupAddress: string, price: number }>>({})
const [shipping, setShipping] = useState({
recipient_name: user?.name || '',
street: '',
@ -27,8 +33,65 @@ export function CheckoutPage() {
country: 'Brasil'
})
// Pre-fill address from company
useEffect(() => {
async function fetchCompanyAddress() {
try {
// TODO: Use a proper service for this
const res = await apiClient.get<any>('/v1/companies/me')
const company = res.data
if (company) {
setShipping(prev => ({
...prev,
street: company.street || '', // Wait, company model fields differ from shipping fields?
// Let's check company model in backend: city, state, etc.
// Actually, the backend Company model has: city, state, latitude, longitude.
// It seems it does NOT have street, number, district, zip_code separately in the main table?
// Let's re-read models.go.
// Ah, lines 18-21: Latitude, Longitude, City, State.
// It seems we lack detailed address fields in Company model for pre-filling!
// Wait, seeder line 113: city, state.
// We might only be able to pre-fill city/state for now unless I missed something.
city: company.city || '',
state: company.state || '',
// We can't pre-fill street/number if they aren't there.
}))
}
} catch (e) {
console.error("Failed to fetch company address", e)
}
}
if (user) fetchCompanyAddress()
}, [user])
// Fetch shipping settings for sellers
useEffect(() => {
async function fetchShipping() {
const options: Record<string, any> = {}
for (const sellerId of Object.keys(groups)) {
try {
const settings = await shippingService.getSettings(sellerId)
options[sellerId] = {
delivery: settings.active,
pickup: settings.pickup_active,
pickupAddress: settings.pickup_address,
price: 15.00 // Mock calc for now, or implement calculateShipping
}
} catch (e) {
console.error(`Failed to fetch shipping for ${sellerId}`, e)
}
}
setShippingOptions(options)
}
if (Object.keys(groups).length > 0) fetchShipping()
}, [groups])
// Payment method selection
const [paymentMethod, setPaymentMethod] = useState<'pix' | 'credit_card' | 'debit_card'>('credit_card')
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setPaymentMethod(prev => prev) // Keep state if needed, though strictly not needed for this simple setter
setShipping(prev => ({ ...prev, [name]: value }))
}
@ -59,7 +122,8 @@ export function CheckoutPage() {
state: shipping.state,
zip_code: shipping.zip_code,
country: shipping.country
}
},
payment_method: paymentMethod
}
return ordersService.createOrder(orderData)
})
@ -192,13 +256,58 @@ export function CheckoutPage() {
</div>
</div>
{/* Payment Method Stub */}
{/* Payment Method Selection */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-800">Pagamento</h2>
<p className="mt-2 text-sm text-gray-600">Este é um ambiente de demonstração. O pagamento será processado como "Confirmado" para fins de teste.</p>
<div className="mt-4 flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="text-sm font-medium text-green-800">Método de Teste (Aprovação Automática)</span>
<div className="mb-4 flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-medicalBlue" />
<h2 className="text-lg font-semibold text-gray-800">Forma de Pagamento</h2>
</div>
<div className="space-y-3">
<label className={`flex cursor-pointer items-center rounded-lg border p-4 transition-colors ${paymentMethod === 'pix' ? 'border-medicalBlue bg-blue-50' : 'border-gray-200 hover:bg-gray-50'}`}>
<input
type="radio"
name="paymentMethod"
value="pix"
checked={paymentMethod === 'pix'}
onChange={() => setPaymentMethod('pix')}
className="h-4 w-4 border-gray-300 text-medicalBlue focus:ring-medicalBlue"
/>
<div className="ml-3">
<span className="block text-sm font-medium text-gray-900">Pix</span>
<span className="block text-sm text-gray-500">Aprovação imediata</span>
</div>
</label>
<label className={`flex cursor-pointer items-center rounded-lg border p-4 transition-colors ${paymentMethod === 'credit_card' ? 'border-medicalBlue bg-blue-50' : 'border-gray-200 hover:bg-gray-50'}`}>
<input
type="radio"
name="paymentMethod"
value="credit_card"
checked={paymentMethod === 'credit_card'}
onChange={() => setPaymentMethod('credit_card')}
className="h-4 w-4 border-gray-300 text-medicalBlue focus:ring-medicalBlue"
/>
<div className="ml-3">
<span className="block text-sm font-medium text-gray-900">Cartão de Crédito</span>
<span className="block text-sm text-gray-500">Em até 12x</span>
</div>
</label>
<label className={`flex cursor-pointer items-center rounded-lg border p-4 transition-colors ${paymentMethod === 'debit_card' ? 'border-medicalBlue bg-blue-50' : 'border-gray-200 hover:bg-gray-50'}`}>
<input
type="radio"
name="paymentMethod"
value="debit_card"
checked={paymentMethod === 'debit_card'}
onChange={() => setPaymentMethod('debit_card')}
className="h-4 w-4 border-gray-300 text-medicalBlue focus:ring-medicalBlue"
/>
<div className="ml-3">
<span className="block text-sm font-medium text-gray-900">Cartão de Débito</span>
<span className="block text-sm text-gray-500">Pagamento à vista</span>
</div>
</label>
</div>
</div>
</div>
@ -211,12 +320,44 @@ export function CheckoutPage() {
{Object.entries(groups).map(([vendorId, group]) => (
<div key={vendorId} className="border-b border-gray-100 pb-4 last:border-0 last:pb-0">
<p className="mb-2 text-sm font-medium text-gray-600">{group.vendorName}</p>
{group.items.map(item => (
<div key={item.id} className="flex justify-between text-sm">
<span className="text-gray-800">{item.quantity}x {item.name}</span>
<span className="text-gray-600">R$ {formatCurrency(item.quantity * item.unitPrice)}</span>
</div>
<div key={item.id} className="flex justify-between text-sm">
<span className="text-gray-800">{item.quantity}x {item.name}</span>
<span className="text-gray-600">R$ {formatCurrency(item.quantity * item.unitPrice)}</span>
</div>
))}
{/* Shipping Options for this Vendor */}
{shippingOptions[vendorId] && (
<div className="mt-3 rounded-md bg-gray-50 p-3">
<p className="mb-2 text-xs font-semibold text-gray-500 uppercase">Entrega</p>
<div className="flex flex-col gap-2">
{shippingOptions[vendorId].delivery ? (
<label className="flex cursor-pointer items-center justify-between text-sm">
<div className="flex items-center">
<input type="radio" name={`shipping-${vendorId}`} className="mr-2 text-medicalBlue focus:ring-medicalBlue" defaultChecked />
<span>Entrega Padrão</span>
</div>
<span className="font-medium text-gray-900">R$ {formatCurrency(shippingOptions[vendorId].price * 100)}</span>
</label>
) : (
<div className="text-sm text-red-500">Entrega indisponível para esta região</div>
)}
{shippingOptions[vendorId].pickup && (
<label className="flex cursor-pointer items-center justify-between text-sm">
<div className="flex items-center">
<input type="radio" name={`shipping-${vendorId}`} className="mr-2 text-medicalBlue focus:ring-medicalBlue" />
<div className="flex flex-col">
<span>Retirada na Loja</span>
<span className="text-xs text-gray-500">{shippingOptions[vendorId].pickupAddress}</span>
</div>
</div>
<span className="font-medium text-green-600">Grátis</span>
</label>
)}
</div>
</div>
)}
</div>
))}
@ -225,6 +366,7 @@ export function CheckoutPage() {
<span>Total</span>
<span className="text-xl text-medicalBlue">R$ {formatCurrency(summary.totalValue)}</span>
</div>
<p className="mt-1 text-xs text-gray-500 text-right">Taxas de entrega não incluídas no total acima</p>
</div>
<button

View file

@ -25,6 +25,7 @@ export interface CreateOrderRequest {
seller_id: string
items: OrderItem[]
shipping: ShippingAddress
payment_method: 'pix' | 'credit_card' | 'debit_card'
}
export const ordersService = {

View file

@ -0,0 +1,38 @@
import { apiClient } from './apiClient'
import { ShippingSettings } from '../types/shipping'
export interface CalculateShippingRequest {
buyer_id: string
order_total_cents: number
items: {
seller_id: string
product_id: string
quantity: number
price_cents: number
}[]
}
export interface CalculateShippingResponse {
options: {
seller_id: string
delivery_fee_cents: number
distance_km: number
estimated_days: number
pickup_available: boolean
pickup_address?: string
pickup_hours?: string
}[]
}
export const shippingService = {
getSettings: async (vendorId: string) => {
const response = await apiClient.get<ShippingSettings>(`/v1/shipping/settings/${vendorId}`)
return response
},
calculate: async (data: CalculateShippingRequest) => {
const response = await apiClient.post<CalculateShippingResponse>('/v1/shipping/calculate', data)
return response
}
}

View file

@ -0,0 +1,14 @@
export interface ShippingSettings {
vendor_id: string
active: boolean
max_radius_km: number
price_per_km_cents: number
min_fee_cents: number
free_shipping_threshold_cents?: number
pickup_active: boolean
pickup_address: string
pickup_hours: string
latitude: number
longitude: number
}

View file

@ -90,6 +90,7 @@ func SeedLean(dsn string) (string, error) {
log.Println("🧹 [Lean] Resetting database...")
// Re-create tables
mustExec(db, `DROP TABLE IF EXISTS shipping_settings CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS shipments CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS inventory_adjustments CASCADE`)
mustExec(db, `DROP TABLE IF EXISTS order_items CASCADE`)
@ -155,6 +156,7 @@ func SeedLean(dsn string) (string, error) {
buyer_id UUID NOT NULL REFERENCES companies(id),
seller_id UUID NOT NULL REFERENCES companies(id),
status TEXT NOT NULL,
payment_method TEXT NOT NULL DEFAULT 'credit_card',
total_cents BIGINT NOT NULL,
shipping_recipient_name TEXT,
shipping_street TEXT,
@ -213,6 +215,22 @@ func SeedLean(dsn string) (string, error) {
updated_at TIMESTAMPTZ NOT NULL
)`)
mustExec(db, `CREATE TABLE shipping_settings (
vendor_id UUID PRIMARY KEY REFERENCES companies(id),
active BOOLEAN NOT NULL DEFAULT TRUE,
max_radius_km DOUBLE PRECISION NOT NULL DEFAULT 20.0,
min_fee_cents BIGINT NOT NULL DEFAULT 500,
price_per_km_cents BIGINT NOT NULL DEFAULT 100,
free_shipping_threshold_cents BIGINT,
pickup_active BOOLEAN NOT NULL DEFAULT TRUE,
pickup_address TEXT NOT NULL,
pickup_hours TEXT NOT NULL,
latitude DOUBLE PRECISION NOT NULL DEFAULT 0,
longitude DOUBLE PRECISION NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)`)
// Helper for hashing
hashPwd := func(pwd string) string {
h, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
@ -276,6 +294,17 @@ func SeedLean(dsn string) (string, error) {
return "", fmt.Errorf("create library %s: %v", ph.Name, err)
}
// 1.1 Create Shipping Settings
address := fmt.Sprintf("Rua Exemplo %s, Anápolis - GO", ph.Suffix)
_, err = db.ExecContext(ctx, `
INSERT INTO shipping_settings (vendor_id, active, max_radius_km, min_fee_cents, price_per_km_cents, free_shipping_threshold_cents, pickup_active, pickup_address, pickup_hours, latitude, longitude, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
companyID, true, 25.0, 500, 200, 15000, true, address, "Seg-Sex: 08:00-18:00", ph.Lat, ph.Lng, now, now,
)
if err != nil {
log.Printf("⚠️ Failed to create shipping settings for %s: %v", ph.Name, err)
}
// 2. Create Users (Dono, Colab, Entregador)
roles := []struct {
Role string
@ -570,7 +599,7 @@ func generateTenants(rng *rand.Rand, count int) []map[string]interface{} {
tenants := make([]map[string]interface{}, 0, count)
for i := 0; i < count; i++ {
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
name := fmt.Sprintf("%s %d", pharmacyNames[rng.Intn(len(pharmacyNames))], i+1)
cnpj := generateCNPJ(rng)
@ -604,7 +633,7 @@ func generateProducts(rng *rand.Rand, sellerID uuid.UUID, count int) []map[strin
products := make([]map[string]interface{}, 0, count)
for i := 0; i < count; i++ {
id, _ := uuid.NewV4()
id, _ := uuid.NewV7()
med := medicamentos[rng.Intn(len(medicamentos))]
// Random expiration: 30 days to 2 years from now