diff --git a/RESTAPI.md b/RESTAPI.md deleted file mode 100644 index 2cb362b..0000000 --- a/RESTAPI.md +++ /dev/null @@ -1,1298 +0,0 @@ -### E-Commerce API Documentation - -Below is a comprehensive list of all API endpoints in the e-commerce system, including their request and response bodies. - -## Table of Contents - -- [Authentication](#authentication) -- [Users](#users) -- [Products](#products) -- [Categories](#categories) -- [Cart](#cart) -- [Orders](#orders) -- [Payment](#payment) -- [Webhooks](#webhooks) - -## Authentication - -All protected endpoints require a JWT token in the Authorization header: - -```plaintext -Authorization: Bearer -``` - -## Users - -### Register User - -```plaintext -POST /api/users/register -``` - -**Request Body:** - -```json -{ - "email": "user@example.com", - "password": "password123", - "first_name": "John", - "last_name": "Doe" -} -``` - -**Response Body:** - -```json -{ - "user": { - "id": 1, - "email": "user@example.com", - "first_name": "John", - "last_name": "Doe", - "role": "user", - "created_at": "2023-04-20T12:00:00Z", - "updated_at": "2023-04-20T12:00:00Z" - }, - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` - -**Status Codes:** - -- `201 Created`: User registered successfully -- `400 Bad Request`: Invalid request body or email already exists - -### Login - -```plaintext -POST /api/users/login -``` - -**Request Body:** - -```json -{ - "email": "user@example.com", - "password": "password123" -} -``` - -**Response Body:** - -```json -{ - "user": { - "id": 1, - "email": "user@example.com", - "first_name": "John", - "last_name": "Doe", - "role": "user", - "created_at": "2023-04-20T12:00:00Z", - "updated_at": "2023-04-20T12:00:00Z" - }, - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." -} -``` - -**Status Codes:** - -- `200 OK`: Login successful -- `401 Unauthorized`: Invalid email or password - -### Get User Profile - -```plaintext -GET /api/users/me -``` - -**Response Body:** - -```json -{ - "id": 1, - "email": "user@example.com", - "first_name": "John", - "last_name": "Doe", - "role": "user", - "created_at": "2023-04-20T12:00:00Z", - "updated_at": "2023-04-20T12:00:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Profile retrieved successfully -- `401 Unauthorized`: Not authenticated - -### Update User Profile - -```plaintext -PUT /api/users/me -``` - -**Request Body:** - -```json -{ - "first_name": "John", - "last_name": "Smith" -} -``` - -**Response Body:** - -```json -{ - "id": 1, - "email": "user@example.com", - "first_name": "John", - "last_name": "Smith", - "role": "user", - "created_at": "2023-04-20T12:00:00Z", - "updated_at": "2023-04-20T12:30:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Profile updated successfully -- `400 Bad Request`: Invalid request body -- `401 Unauthorized`: Not authenticated - -### Change Password - -```plaintext -PUT /api/users/me/password -``` - -**Request Body:** - -```json -{ - "current_password": "password123", - "new_password": "newpassword123" -} -``` - -**Response Body:** - -```json -{ - "message": "Password changed successfully" -} -``` - -**Status Codes:** - -- `200 OK`: Password changed successfully -- `400 Bad Request`: Invalid request body or current password is incorrect -- `401 Unauthorized`: Not authenticated - -### List Users (Admin Only) - -```plaintext -GET /api/admin/users -``` - -**Query Parameters:** - -- `offset` (optional): Pagination offset (default: 0) -- `limit` (optional): Pagination limit (default: 10) - -**Response Body:** - -```json -[ - { - "id": 1, - "email": "user@example.com", - "first_name": "John", - "last_name": "Smith", - "role": "user", - "created_at": "2023-04-20T12:00:00Z", - "updated_at": "2023-04-20T12:30:00Z" - }, - { - "id": 2, - "email": "admin@example.com", - "first_name": "Admin", - "last_name": "User", - "role": "admin", - "created_at": "2023-04-19T10:00:00Z", - "updated_at": "2023-04-19T10:00:00Z" - } -] -``` - -**Status Codes:** - -- `200 OK`: Users retrieved successfully -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not an admin) - -## Products - -### List Products - -```plaintext -GET /api/products -``` - -**Query Parameters:** - -- `offset` (optional): Pagination offset (default: 0) -- `limit` (optional): Pagination limit (default: 10) - -**Response Body:** - -```json -[ - { - "id": 1, - "name": "Smartphone", - "description": "Latest smartphone model", - "price": 999.99, - "stock": 50, - "category_id": 1, - "seller_id": 2, - "images": ["smartphone.jpg"], - "has_variants": false, - "created_at": "2023-04-15T10:00:00Z", - "updated_at": "2023-04-15T10:00:00Z" - }, - { - "id": 2, - "name": "Laptop", - "description": "Powerful laptop for professionals", - "price": 1499.99, - "stock": 25, - "category_id": 1, - "seller_id": 2, - "images": ["laptop.jpg"], - "has_variants": true, - "created_at": "2023-04-16T11:00:00Z", - "updated_at": "2023-04-16T11:00:00Z" - } -] -``` - -**Status Codes:** - -- `200 OK`: Products retrieved successfully - -### Get Product - -```plaintext -GET /api/products/{id} -``` - -**Response Body:** - -```json -{ - "id": 1, - "name": "Smartphone", - "description": "Latest smartphone model", - "price": 999.99, - "stock": 50, - "category_id": 1, - "seller_id": 2, - "images": ["smartphone.jpg"], - "has_variants": false, - "created_at": "2023-04-15T10:00:00Z", - "updated_at": "2023-04-15T10:00:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Product retrieved successfully -- `404 Not Found`: Product not found - -### Search Products - -```plaintext -GET /api/products/search -``` - -**Query Parameters:** - -- `q` (optional): Search query -- `category` (optional): Category ID -- `min_price` (optional): Minimum price -- `max_price` (optional): Maximum price -- `offset` (optional): Pagination offset (default: 0) -- `limit` (optional): Pagination limit (default: 10) - -**Response Body:** - -```json -[ - { - "id": 1, - "name": "Smartphone", - "description": "Latest smartphone model", - "price": 999.99, - "stock": 50, - "category_id": 1, - "seller_id": 2, - "images": ["smartphone.jpg"], - "has_variants": false, - "created_at": "2023-04-15T10:00:00Z", - "updated_at": "2023-04-15T10:00:00Z" - } -] -``` - -**Status Codes:** - -- `200 OK`: Search results retrieved successfully - -### Create Product (Seller Only) - -```plaintext -POST /api/products -``` - -**Request Body:** - -```json -{ - "name": "New Product", - "description": "Product description", - "price": 199.99, - "stock": 100, - "category_id": 1, - "images": ["product.jpg"], - "has_variants": false -} -``` - -**Response Body:** - -```json -{ - "id": 3, - "name": "New Product", - "description": "Product description", - "price": 199.99, - "stock": 100, - "category_id": 1, - "seller_id": 2, - "images": ["product.jpg"], - "has_variants": false, - "created_at": "2023-04-20T14:00:00Z", - "updated_at": "2023-04-20T14:00:00Z" -} -``` - -**Status Codes:** - -- `201 Created`: Product created successfully -- `400 Bad Request`: Invalid request body -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not a seller) - -### Update Product (Seller Only) - -```plaintext -PUT /api/products/{id} -``` - -**Request Body:** - -```json -{ - "name": "Updated Product", - "description": "Updated description", - "price": 249.99, - "stock": 75, - "category_id": 1, - "images": ["updated-product.jpg"] -} -``` - -**Response Body:** - -```json -{ - "id": 3, - "name": "Updated Product", - "description": "Updated description", - "price": 249.99, - "stock": 75, - "category_id": 1, - "seller_id": 2, - "images": ["updated-product.jpg"], - "has_variants": false, - "created_at": "2023-04-20T14:00:00Z", - "updated_at": "2023-04-20T14:30:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Product updated successfully -- `400 Bad Request`: Invalid request body -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not the seller of this product) -- `404 Not Found`: Product not found - -### Delete Product (Seller Only) - -```plaintext -DELETE /api/products/{id} -``` - -**Status Codes:** - -- `204 No Content`: Product deleted successfully -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not the seller of this product) -- `404 Not Found`: Product not found - -### List Seller Products (Seller Only) - -```plaintext -GET /api/products/seller -``` - -**Query Parameters:** - -- `offset` (optional): Pagination offset (default: 0) -- `limit` (optional): Pagination limit (default: 10) - -**Response Body:** - -```json -[ - { - "id": 3, - "name": "Updated Product", - "description": "Updated description", - "price": 249.99, - "stock": 75, - "category_id": 1, - "seller_id": 2, - "images": ["updated-product.jpg"], - "has_variants": false, - "created_at": "2023-04-20T14:00:00Z", - "updated_at": "2023-04-20T14:30:00Z" - } -] -``` - -**Status Codes:** - -- `200 OK`: Products retrieved successfully -- `401 Unauthorized`: Not authenticated - -### Add Product Variant (Seller Only) - -```plaintext -POST /api/products/{productId}/variants -``` - -**Request Body:** - -```json -{ - "sku": "PROD-RED-M", - "price": 29.99, - "compare_price": 39.99, - "stock": 10, - "attributes": [ - { "name": "Color", "value": "Red" }, - { "name": "Size", "value": "Medium" } - ], - "images": ["red-shirt.jpg"], - "is_default": true -} -``` - -**Response Body:** - -```json -{ - "id": 1, - "product_id": 2, - "sku": "PROD-RED-M", - "price": 29.99, - "compare_price": 39.99, - "stock": 10, - "attributes": [ - { "name": "Color", "value": "Red" }, - { "name": "Size", "value": "Medium" } - ], - "images": ["red-shirt.jpg"], - "is_default": true, - "created_at": "2023-04-20T15:00:00Z", - "updated_at": "2023-04-20T15:00:00Z" -} -``` - -**Status Codes:** - -- `201 Created`: Variant created successfully -- `400 Bad Request`: Invalid request body -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not the seller of this product) -- `404 Not Found`: Product not found - -### Update Product Variant (Seller Only) - -```plaintext -PUT /api/products/{productId}/variants/{variantId} -``` - -**Request Body:** - -```json -{ - "sku": "PROD-RED-M", - "price": 24.99, - "compare_price": 34.99, - "stock": 15, - "attributes": [ - { "name": "Color", "value": "Red" }, - { "name": "Size", "value": "Medium" } - ], - "images": ["red-shirt-updated.jpg"], - "is_default": true -} -``` - -**Response Body:** - -```json -{ - "id": 1, - "product_id": 2, - "sku": "PROD-RED-M", - "price": 24.99, - "compare_price": 34.99, - "stock": 15, - "attributes": [ - { "name": "Color", "value": "Red" }, - { "name": "Size", "value": "Medium" } - ], - "images": ["red-shirt-updated.jpg"], - "is_default": true, - "created_at": "2023-04-20T15:00:00Z", - "updated_at": "2023-04-20T15:30:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Variant updated successfully -- `400 Bad Request`: Invalid request body -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not the seller of this product) -- `404 Not Found`: Product or variant not found - -### Delete Product Variant (Seller Only) - -```plaintext -DELETE /api/products/{productId}/variants/{variantId} -``` - -**Status Codes:** - -- `204 No Content`: Variant deleted successfully -- `400 Bad Request`: Cannot delete the only variant of a product -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not the seller of this product) -- `404 Not Found`: Product or variant not found - -## Categories - -### List Categories - -```plaintext -GET /api/categories -``` - -**Response Body:** - -```json -[ - { - "id": 1, - "name": "Electronics", - "description": "Electronic devices and accessories", - "parent_id": null, - "created_at": "2023-04-10T10:00:00Z", - "updated_at": "2023-04-10T10:00:00Z" - }, - { - "id": 2, - "name": "Smartphones", - "description": "Mobile phones and accessories", - "parent_id": 1, - "created_at": "2023-04-10T10:05:00Z", - "updated_at": "2023-04-10T10:05:00Z" - } -] -``` - -**Status Codes:** - -- `200 OK`: Categories retrieved successfully - -## Cart - -### Get Cart - -```plaintext -GET /api/cart -``` - -**Response Body:** - -```json -{ - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "cart_id": 1, - "product_id": 1, - "quantity": 2, - "created_at": "2023-04-20T16:00:00Z", - "updated_at": "2023-04-20T16:00:00Z" - } - ], - "created_at": "2023-04-20T15:45:00Z", - "updated_at": "2023-04-20T16:00:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Cart retrieved successfully -- `401 Unauthorized`: Not authenticated -- `404 Not Found`: Cart not found - -### Add to Cart - -```plaintext -POST /api/cart/items -``` - -**Request Body:** - -```json -{ - "product_id": 2, - "variant_id": 5, - "quantity": 1 -} -``` - -**Response Body:** - -```json -{ - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "cart_id": 1, - "product_id": 1, - "product_variant_id": 3, - "quantity": 2, - "created_at": "2023-04-20T16:00:00Z", - "updated_at": "2023-04-20T16:00:00Z" - }, - { - "id": 2, - "cart_id": 1, - "product_id": 2, - "product_variant_id": 5, - "quantity": 1, - "created_at": "2023-04-20T16:15:00Z", - "updated_at": "2023-04-20T16:15:00Z" - } - ], - "created_at": "2023-04-20T15:00:00Z", - "updated_at": "2023-04-20T16:15:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Item added to cart successfully -- `400 Bad Request`: Invalid request body, product not found, or insufficient stock -- `401 Unauthorized`: Not authenticated (for user cart operations) - -### Update Cart Item - -```plaintext -PUT /api/cart/items/{productId} -``` - -**Request Body:** - -```json -{ - "quantity": 3, - "variant_id": 5 -} -``` - -**Response Body:** - -```json -{ - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "cart_id": 1, - "product_id": 1, - "product_variant_id": 3, - "quantity": 2, - "created_at": "2023-04-20T16:00:00Z", - "updated_at": "2023-04-20T16:00:00Z" - }, - { - "id": 2, - "cart_id": 1, - "product_id": 2, - "product_variant_id": 5, - "quantity": 3, - "created_at": "2023-04-20T16:15:00Z", - "updated_at": "2023-04-20T16:20:00Z" - } - ], - "created_at": "2023-04-20T15:00:00Z", - "updated_at": "2023-04-20T16:20:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Cart item updated successfully -- `400 Bad Request`: Invalid request body, product not found, or insufficient stock -- `401 Unauthorized`: Not authenticated (for user cart operations) - -### Remove from Cart - -```plaintext -DELETE /api/cart/items/{productId}?variantId={variantId} -``` - -**Response Body:** - -```json -{ - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "cart_id": 1, - "product_id": 1, - "product_variant_id": 3, - "quantity": 2, - "created_at": "2023-04-20T16:00:00Z", - "updated_at": "2023-04-20T16:00:00Z" - } - ], - "created_at": "2023-04-20T15:00:00Z", - "updated_at": "2023-04-20T16:25:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Cart item removed successfully -- `400 Bad Request`: Product not found in cart -- `401 Unauthorized`: Not authenticated (for user cart operations) - -### Clear Cart - -```plaintext -DELETE /api/cart -``` - -**Response Body:** - -```json -{ - "id": 1, - "user_id": 1, - "items": [], - "created_at": "2023-04-20T15:45:00Z", - "updated_at": "2023-04-20T17:00:00Z" -} -``` - -**Status Codes:** - -- `200 OK`: Cart cleared successfully -- `401 Unauthorized`: Not authenticated - -```plaintext -POST /api/orders -``` - -**Request Body:** - -```json -{ - "shipping_addr": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "billing_addr": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - } -} -``` - -**Response Body:** - -```json -{ - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "order_id": 1, - "product_id": 2, - "quantity": 1, - "price": 1499.99, - "subtotal": 1499.99 - } - ], - "total_amount": 1499.99, - "status": "pending", - "shipping_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "billing_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "payment_id": "", - "payment_provider": "", - "tracking_code": "", - "created_at": "2023-04-20T17:30:00Z", - "updated_at": "2023-04-20T17:30:00Z", - "completed_at": null -} -``` - -**Status Codes:** - -- `201 Created`: Order created successfully -- `400 Bad Request`: Invalid request body, cart is empty, or insufficient stock -- `401 Unauthorized`: Not authenticated - -### Get Order - -```plaintext -GET /api/orders/{id} -``` - -**Response Body:** - -```json -{ - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "order_id": 1, - "product_id": 2, - "quantity": 1, - "price": 1499.99, - "subtotal": 1499.99 - } - ], - "total_amount": 1499.99, - "status": "pending", - "shipping_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "billing_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "payment_id": "", - "payment_provider": "", - "tracking_code": "", - "created_at": "2023-04-20T17:30:00Z", - "updated_at": "2023-04-20T17:30:00Z", - "completed_at": null -} -``` - -**Status Codes:** - -- `200 OK`: Order retrieved successfully -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not the owner of this order) -- `404 Not Found`: Order not found - -### List Orders - -```plaintext -GET /api/orders -``` - -**Query Parameters:** - -- `offset` (optional): Pagination offset (default: 0) -- `limit` (optional): Pagination limit (default: 10) - -**Response Body:** - -```json -[ - { - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "order_id": 1, - "product_id": 2, - "quantity": 1, - "price": 1499.99, - "subtotal": 1499.99 - } - ], - "total_amount": 1499.99, - "status": "pending", - "shipping_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "billing_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "payment_id": "", - "payment_provider": "", - "tracking_code": "", - "created_at": "2023-04-20T17:30:00Z", - "updated_at": "2023-04-20T17:30:00Z", - "completed_at": null - } -] -``` - -**Status Codes:** - -- `200 OK`: Orders retrieved successfully -- `401 Unauthorized`: Not authenticated - -### Process Payment - -```plaintext -POST /api/orders/{id}/payment -``` - -**Request Body:** - -```json -{ - "payment_method": "credit_card", - "payment_provider": "stripe", - "card_details": { - "card_number": "4242424242424242", - "expiry_month": 12, - "expiry_year": 2025, - "cvv": "123", - "cardholder_name": "John Doe", - "token": "tok_visa" - }, - "customer_email": "user@example.com" -} -``` - -**Response Body:** - -```json -{ - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "order_id": 1, - "product_id": 2, - "quantity": 1, - "price": 1499.99, - "subtotal": 1499.99 - } - ], - "total_amount": 1499.99, - "status": "paid", - "shipping_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "billing_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "payment_id": "pi_3MkCrjKZ6o8QJAcJ0KjkLNZt", - "payment_provider": "stripe", - "tracking_code": "", - "created_at": "2023-04-20T17:30:00Z", - "updated_at": "2023-04-20T18:00:00Z", - "completed_at": null -} -``` - -**Status Codes:** - -- `200 OK`: Payment processed successfully -- `400 Bad Request`: Invalid request body, payment failed, or order already paid -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not the owner of this order) -- `404 Not Found`: Order not found - -### List All Orders (Admin Only) - -```plaintext -GET /api/admin/orders -``` - -**Query Parameters:** - -- `offset` (optional): Pagination offset (default: 0) -- `limit` (optional): Pagination limit (default: 10) -- `status` (optional): Filter by order status - -**Response Body:** - -```json -[ - { - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "order_id": 1, - "product_id": 2, - "quantity": 1, - "price": 1499.99, - "subtotal": 1499.99 - } - ], - "total_amount": 1499.99, - "status": "paid", - "shipping_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "billing_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "payment_id": "pi_3MkCrjKZ6o8QJAcJ0KjkLNZt", - "payment_provider": "stripe", - "tracking_code": "", - "created_at": "2023-04-20T17:30:00Z", - "updated_at": "2023-04-20T18:00:00Z", - "completed_at": null - } -] -``` - -**Status Codes:** - -- `200 OK`: Orders retrieved successfully -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not an admin) - -### Update Order Status (Admin Only) - -```plaintext -PUT /api/admin/orders/{id}/status -``` - -**Request Body:** - -```json -{ - "status": "shipped" -} -``` - -**Response Body:** - -```json -{ - "id": 1, - "user_id": 1, - "items": [ - { - "id": 1, - "order_id": 1, - "product_id": 2, - "quantity": 1, - "price": 1499.99, - "subtotal": 1499.99 - } - ], - "total_amount": 1499.99, - "status": "shipped", - "shipping_address": { 1499.99 - } - ], - "total_amount": 1499.99, - "status": "shipped", - "shipping_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "billing_address": { - "street": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "USA" - }, - "payment_id": "pi_3MkCrjKZ6o8QJAcJ0KjkLNZt", - "payment_provider": "stripe", - "tracking_code": "TRACK123456", - "created_at": "2023-04-20T17:30:00Z", - "updated_at": "2023-04-20T19:00:00Z", - "completed_at": null -} -``` - -**Status Codes:** - -- `200 OK`: Order status updated successfully -- `400 Bad Request`: Invalid request body -- `401 Unauthorized`: Not authenticated -- `403 Forbidden`: Not authorized (not an admin) -- `404 Not Found`: Order not found - -## Payment - -### Get Available Payment Providers - -```plaintext -GET /api/payment/providers -``` - -**Response Body:** - -```json -[ - { - "type": "stripe", - "name": "Stripe", - "description": "Pay with credit or debit card", - "icon_url": "/assets/images/stripe-logo.png", - "methods": ["credit_card"], - "enabled": true - }, - { - "type": "paypal", - "name": "PayPal", - "description": "Pay with your PayPal account", - "icon_url": "/assets/images/paypal-logo.png", - "methods": ["paypal"], - "enabled": true - }, - { - "type": "mock", - "name": "Test Payment", - "description": "For testing purposes only", - "methods": ["credit_card", "paypal", "bank_transfer"], - "enabled": true - } -] -``` - -**Status Codes:** - -- `200 OK`: Payment providers retrieved successfully - -## Webhooks - -### Stripe Webhook - -```plaintext -POST /api/webhooks/stripe -``` - -**Request Headers:** - -- `Stripe-Signature`: Webhook signature from Stripe - -**Request Body:** - -- Stripe event object (varies based on event type) - -**Response Body:** - -```json -{ - "status": "success" -} -``` - -**Status Codes:** - -- `200 OK`: Webhook processed successfully -- `400 Bad Request`: Invalid webhook signature or event - -This completes the comprehensive API documentation for the e-commerce system, including all endpoints with their request and response bodies. diff --git a/config/config.go b/config/config.go index 387fdd3..729b716 100644 --- a/config/config.go +++ b/config/config.go @@ -8,15 +8,16 @@ import ( // Config holds all configuration for the application type Config struct { - Server ServerConfig - Database DatabaseConfig - Auth AuthConfig - Payment PaymentConfig - Email EmailConfig - Stripe StripeConfig - PayPal PayPalConfig - MobilePay MobilePayConfig - CORS CORSConfig + Server ServerConfig + Database DatabaseConfig + Auth AuthConfig + Payment PaymentConfig + Email EmailConfig + Stripe StripeConfig + PayPal PayPalConfig + MobilePay MobilePayConfig + CORS CORSConfig + DefaultCurrency string // Default currency for the store } // ServerConfig holds server-specific configuration @@ -224,6 +225,7 @@ func LoadConfig() (*Config, error) { AllowedOrigins: []string{"*"}, AllowAllOrigins: true, }, + DefaultCurrency: getEnv("DEFAULT_CURRENCY", "USD"), }, nil } diff --git a/docs/currency_api_examples.md b/docs/currency_api_examples.md new file mode 100644 index 0000000..1bd93cd --- /dev/null +++ b/docs/currency_api_examples.md @@ -0,0 +1,395 @@ +# Currency API Examples + +This document provides example request bodies for the currency system API endpoints. + +## Public Currency Endpoints + +### List Enabled Currencies + +```plaintext +GET /api/currencies +``` + +Retrieve all currencies that are enabled in the system. + +Example response: + +```json +[ + { + "code": "USD", + "name": "US Dollar", + "symbol": "$", + "exchange_rate": 1.0, + "is_enabled": true, + "is_default": true, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z" + }, + { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "exchange_rate": 0.85, + "is_enabled": true, + "is_default": false, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z" + }, + { + "code": "GBP", + "name": "British Pound", + "symbol": "£", + "exchange_rate": 0.75, + "is_enabled": true, + "is_default": false, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z" + } +] +``` + +**Status Codes:** + +- `200 OK`: Currencies retrieved successfully +- `500 Internal Server Error`: Failed to retrieve currencies + +### Get Default Currency + +```plaintext +GET /api/currencies/default +``` + +Retrieve the default currency used in the system. + +Example response: + +```json +{ + "code": "USD", + "name": "US Dollar", + "symbol": "$", + "exchange_rate": 1.0, + "is_enabled": true, + "is_default": true, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z" +} +``` + +**Status Codes:** + +- `200 OK`: Default currency retrieved successfully +- `404 Not Found`: Default currency not found +- `500 Internal Server Error`: Failed to retrieve default currency + +### Convert Currency Amount + +```plaintext +POST /api/currencies/convert +``` + +Convert an amount from one currency to another. + +**Request Body:** + +```json +{ + "amount": 100.0, + "from_currency": "USD", + "to_currency": "EUR" +} +``` + +Example response: + +```json +{ + "from": { + "currency": "USD", + "amount": 100.0, + "cents": 10000 + }, + "to": { + "currency": "EUR", + "amount": 85.0, + "cents": 8500 + } +} +``` + +**Status Codes:** + +- `200 OK`: Amount converted successfully +- `400 Bad Request`: Invalid request body or currency not found +- `500 Internal Server Error`: Failed to convert amount + +## Admin Currency Endpoints + +### List All Currencies + +```plaintext +GET /api/admin/currencies/all +``` + +List all currencies in the system, including disabled ones (admin only). + +Example response: + +```json +[ + { + "code": "USD", + "name": "US Dollar", + "symbol": "$", + "exchange_rate": 1.0, + "is_enabled": true, + "is_default": true, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z" + }, + { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "exchange_rate": 0.85, + "is_enabled": true, + "is_default": false, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z" + }, + { + "code": "GBP", + "name": "British Pound", + "symbol": "£", + "exchange_rate": 0.75, + "is_enabled": true, + "is_default": false, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z" + }, + { + "code": "JPY", + "name": "Japanese Yen", + "symbol": "¥", + "exchange_rate": 110.0, + "is_enabled": false, + "is_default": false, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-01-01T00:00:00Z" + } +] +``` + +**Status Codes:** + +- `200 OK`: Currencies retrieved successfully +- `401 Unauthorized`: Not authenticated +- `403 Forbidden`: Not authorized (not an admin) +- `500 Internal Server Error`: Failed to retrieve currencies + +### Create Currency + +```plaintext +POST /api/admin/currencies +``` + +Create a new currency (admin only). + +**Request Body:** + +```json +{ + "code": "CAD", + "name": "Canadian Dollar", + "symbol": "C$", + "exchange_rate": 1.25, + "is_enabled": true, + "is_default": false +} +``` + +Example response: + +```json +{ + "code": "CAD", + "name": "Canadian Dollar", + "symbol": "C$", + "exchange_rate": 1.25, + "is_enabled": true, + "is_default": false, + "created_at": "2025-05-08T15:30:45Z", + "updated_at": "2025-05-08T15:30:45Z" +} +``` + +**Status Codes:** + +- `201 Created`: Currency created successfully +- `400 Bad Request`: Invalid request body or currency code already exists +- `401 Unauthorized`: Not authenticated +- `403 Forbidden`: Not authorized (not an admin) +- `500 Internal Server Error`: Failed to create currency + +### Update Currency + +```plaintext +PUT /api/admin/currencies?code={code} +``` + +Update an existing currency (admin only). + +**Request Body:** + +```json +{ + "name": "Canadian Dollar", + "symbol": "CA$", + "exchange_rate": 1.27, + "is_enabled": true, + "is_default": false +} +``` + +Example response: + +```json +{ + "code": "CAD", + "name": "Canadian Dollar", + "symbol": "CA$", + "exchange_rate": 1.27, + "is_enabled": true, + "is_default": false, + "created_at": "2025-05-08T15:30:45Z", + "updated_at": "2025-05-08T15:45:22Z" +} +``` + +**Status Codes:** + +- `200 OK`: Currency updated successfully +- `400 Bad Request`: Invalid request body or currency not found +- `401 Unauthorized`: Not authenticated +- `403 Forbidden`: Not authorized (not an admin) +- `500 Internal Server Error`: Failed to update currency + +### Delete Currency + +```plaintext +DELETE /api/admin/currencies?code={code} +``` + +Delete a currency (admin only). Cannot delete the default currency. + +Example response: + +```json +{ + "status": "success", + "message": "Currency deleted successfully" +} +``` + +**Status Codes:** + +- `200 OK`: Currency deleted successfully +- `400 Bad Request`: Cannot delete default currency +- `401 Unauthorized`: Not authenticated +- `403 Forbidden`: Not authorized (not an admin) +- `404 Not Found`: Currency not found +- `500 Internal Server Error`: Failed to delete currency + +### Set Default Currency + +```plaintext +PUT /api/admin/currencies/default?code={code} +``` + +Set a currency as the default currency (admin only). + +Example response: + +```json +{ + "code": "EUR", + "name": "Euro", + "symbol": "€", + "exchange_rate": 0.85, + "is_enabled": true, + "is_default": true, + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2025-05-08T16:15:30Z" +} +``` + +**Status Codes:** + +- `200 OK`: Default currency set successfully +- `400 Bad Request`: Invalid currency code +- `401 Unauthorized`: Not authenticated +- `403 Forbidden`: Not authorized (not an admin) +- `404 Not Found`: Currency not found +- `500 Internal Server Error`: Failed to set default currency + +## Multi-Currency Support + +The system supports selling products in multiple currencies. When creating or updating products, you can specify prices in different currencies. + +### Product with Multi-Currency Prices + +When retrieving products, you can view prices in the store's default currency or a specific currency by using the appropriate endpoints. + +Example of a product with multiple currency prices: + +```json +{ + "id": 1, + "product_number": "PROD-000001", + "name": "Smartphone", + "description": "Latest smartphone model", + "price": 999.99, + "prices": [ + { + "currency_code": "USD", + "price": 999.99, + "compare_price": 1099.99 + }, + { + "currency_code": "EUR", + "price": 849.99, + "compare_price": 934.99 + }, + { + "currency_code": "GBP", + "price": 749.99, + "compare_price": 824.99 + } + ], + "stock": 50, + "weight": 0.35, + "category_id": 1, + "seller_id": 2, + "images": ["smartphone.jpg"], + "has_variants": false, + "created_at": "2023-04-15T10:00:00Z", + "updated_at": "2023-04-15T10:00:00Z" +} +``` + +## Example Workflows + +### Setting Up Multi-Currency Support + +1. Admin creates different currencies with appropriate exchange rates +2. Admin sets one currency as the default currency +3. Sellers can specify prices in different currencies for their products +4. Customers can view prices in their preferred currency + +### Currency Conversion Process + +1. Customer selects a non-default currency +2. System converts all product prices to the selected currency using the exchange rates +3. All prices throughout the store are displayed in the selected currency +4. Orders are still processed in the system's default currency diff --git a/docs/product_api_examples.md b/docs/product_api_examples.md index 6eea898..5675dba 100644 --- a/docs/product_api_examples.md +++ b/docs/product_api_examples.md @@ -100,6 +100,10 @@ Example response: Get details of a specific product. +**Query Parameters:** + +- `currency` (optional): Currency code to display prices in (e.g., "EUR", "GBP") + Example response: ```json @@ -175,6 +179,7 @@ Search products based on various criteria. - `category` (optional): Category ID - `min_price` (optional): Minimum price - `max_price` (optional): Maximum price +- `currency_code` (optional): Currency code for price filtering and display - `offset` (optional): Pagination offset (default: 0) - `limit` (optional): Pagination limit (default: 10) @@ -277,11 +282,24 @@ Create a new product (seller only). "name": "New Product", "description": "Product description", "price": 199.99, + "compare_price": 249.99, "stock": 100, "weight": 1.5, "category_id": 1, "images": ["product.jpg"], - "has_variants": false + "has_variants": false, + "currency_prices": [ + { + "currency_code": "EUR", + "price": 169.99, + "compare_price": 212.49 + }, + { + "currency_code": "GBP", + "price": 149.99, + "compare_price": 187.49 + } + ] } ``` @@ -326,7 +344,19 @@ Update an existing product (seller only). "stock": 75, "weight": 1.6, "category_id": 1, - "images": ["updated-product.jpg"] + "images": ["updated-product.jpg"], + "currency_prices": [ + { + "currency_code": "EUR", + "price": 212.49, + "compare_price": 254.99 + }, + { + "currency_code": "GBP", + "price": 187.49, + "compare_price": 224.99 + } + ] } ``` @@ -462,7 +492,19 @@ Add a variant to a product (seller only). "size": "Medium" }, "images": ["red-shirt.jpg"], - "is_default": true + "is_default": true, + "currency_prices": [ + { + "currency_code": "EUR", + "price": 25.49, + "compare_price": 33.99 + }, + { + "currency_code": "GBP", + "price": 22.49, + "compare_price": 29.99 + } + ] } ``` @@ -514,7 +556,19 @@ Update a product variant (seller only). "size": "Medium" }, "images": ["red-shirt-updated.jpg"], - "is_default": true + "is_default": true, + "currency_prices": [ + { + "currency_code": "EUR", + "price": 21.24, + "compare_price": 29.74 + }, + { + "currency_code": "GBP", + "price": 18.74, + "compare_price": 26.24 + } + ] } ``` @@ -562,6 +616,28 @@ Delete a product variant (seller only). - `403 Forbidden`: Not authorized (not the seller of this product) - `404 Not Found`: Product or variant not found +## Multi-Currency Product Management + +### Setting Product Currency Prices + +When creating or updating products and their variants, you can specify prices in multiple currencies using the `currency_prices` array property. Each entry in this array should include: + +- `currency_code`: The three-letter ISO code of the currency (e.g., "USD", "EUR", "GBP") +- `price`: The price in the specified currency +- `compare_price` (optional): The compare price (original/before discount price) in the specified currency + +The system always requires a price in the default currency, and additional currency prices are optional. If a currency price is not specified for a particular currency, the system will automatically convert the price from the default currency using the current exchange rate when needed. + +### Retrieving Products with Specific Currency Prices + +When retrieving products, you can specify a currency code in the query parameters to get prices in that currency: + +``` +GET /api/products/1?currency=EUR +``` + +This will return the product with prices in euros, either using the explicitly set euro prices or converting from the default currency if no specific euro prices are set. + ## Example Workflow ### Product Management Flow (Seller) diff --git a/internal/application/usecase/currency_usecase.go b/internal/application/usecase/currency_usecase.go new file mode 100644 index 0000000..b5e1036 --- /dev/null +++ b/internal/application/usecase/currency_usecase.go @@ -0,0 +1,163 @@ +package usecase + +import ( + "errors" + "strings" + + "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/domain/repository" +) + +// CurrencyUseCase implements currency-related use cases +type CurrencyUseCase struct { + currencyRepo repository.CurrencyRepository +} + +// NewCurrencyUseCase creates a new CurrencyUseCase +func NewCurrencyUseCase(currencyRepo repository.CurrencyRepository) *CurrencyUseCase { + return &CurrencyUseCase{ + currencyRepo: currencyRepo, + } +} + +// CurrencyInput represents input data for creating or updating a currency +type CurrencyInput struct { + Code string `json:"code"` + Name string `json:"name"` + Symbol string `json:"symbol"` + ExchangeRate float64 `json:"exchange_rate"` + IsEnabled bool `json:"is_enabled"` + IsDefault bool `json:"is_default"` +} + +// CreateCurrency creates a new currency +func (uc *CurrencyUseCase) CreateCurrency(input CurrencyInput) (*entity.Currency, error) { + // Check if currency with this code already exists + existingCurrency, err := uc.currencyRepo.GetByCode(input.Code) + if err == nil && existingCurrency != nil { + return nil, errors.New("currency with this code already exists") + } + + // Create new currency entity + currency, err := entity.NewCurrency( + input.Code, + input.Name, + input.Symbol, + input.ExchangeRate, + input.IsEnabled, + input.IsDefault, + ) + if err != nil { + return nil, err + } + + // Persist the currency + err = uc.currencyRepo.Create(currency) + if err != nil { + return nil, err + } + + return currency, nil +} + +// UpdateCurrency updates an existing currency +func (uc *CurrencyUseCase) UpdateCurrency(code string, input CurrencyInput) (*entity.Currency, error) { + // Convert code to uppercase for consistency + code = strings.ToUpper(code) + + // Get the existing currency + currency, err := uc.currencyRepo.GetByCode(code) + if err != nil { + return nil, err + } + + // Update fields + if input.Name != "" { + currency.Name = input.Name + } + + if input.Symbol != "" { + currency.Symbol = input.Symbol + } + + if input.ExchangeRate > 0 { + if err := currency.SetExchangeRate(input.ExchangeRate); err != nil { + return nil, err + } + } + + // Handle enabled/disabled state + if currency.IsEnabled != input.IsEnabled { + if input.IsEnabled { + currency.Enable() + } else { + if err := currency.Disable(); err != nil { + return nil, err + } + } + } + + // Handle default state + if input.IsDefault && !currency.IsDefault { + currency.SetAsDefault() + } else if !input.IsDefault && currency.IsDefault { + // If this is the current default and we're trying to unset it, + // don't allow it - require setting a different currency as default first + return nil, errors.New("cannot unset the default currency - set another currency as default first") + } + + // Update in repository + err = uc.currencyRepo.Update(currency) + if err != nil { + return nil, err + } + + return currency, nil +} + +// DeleteCurrency deletes a currency +func (uc *CurrencyUseCase) DeleteCurrency(code string) error { + return uc.currencyRepo.Delete(code) +} + +// GetCurrency gets a currency by its code +func (uc *CurrencyUseCase) GetCurrency(code string) (*entity.Currency, error) { + return uc.currencyRepo.GetByCode(code) +} + +// GetDefaultCurrency gets the default currency +func (uc *CurrencyUseCase) GetDefaultCurrency() (*entity.Currency, error) { + return uc.currencyRepo.GetDefault() +} + +// ListCurrencies lists all currencies +func (uc *CurrencyUseCase) ListCurrencies() ([]*entity.Currency, error) { + return uc.currencyRepo.List() +} + +// ListEnabledCurrencies lists all enabled currencies +func (uc *CurrencyUseCase) ListEnabledCurrencies() ([]*entity.Currency, error) { + return uc.currencyRepo.ListEnabled() +} + +// SetDefaultCurrency sets a currency as the default currency +func (uc *CurrencyUseCase) SetDefaultCurrency(code string) error { + return uc.currencyRepo.SetDefault(code) +} + +// ConvertPrice converts a price from one currency to another +func (uc *CurrencyUseCase) ConvertPrice(amount int64, fromCurrencyCode, toCurrencyCode string) (int64, error) { + // Get the currencies + fromCurrency, err := uc.currencyRepo.GetByCode(fromCurrencyCode) + if err != nil { + return 0, err + } + + toCurrency, err := uc.currencyRepo.GetByCode(toCurrencyCode) + if err != nil { + return 0, err + } + + // Convert the amount + return fromCurrency.ConvertAmount(amount, toCurrency), nil +} diff --git a/internal/application/usecase/product_usecase.go b/internal/application/usecase/product_usecase.go index 7fdf76f..fb2e4fc 100644 --- a/internal/application/usecase/product_usecase.go +++ b/internal/application/usecase/product_usecase.go @@ -13,6 +13,7 @@ type ProductUseCase struct { productRepo repository.ProductRepository categoryRepo repository.CategoryRepository productVariantRepo repository.ProductVariantRepository + currencyRepo repository.CurrencyRepository } // NewProductUseCase creates a new ProductUseCase @@ -20,38 +21,49 @@ func NewProductUseCase( productRepo repository.ProductRepository, categoryRepo repository.CategoryRepository, productVariantRepo repository.ProductVariantRepository, + currencyRepo repository.CurrencyRepository, ) *ProductUseCase { return &ProductUseCase{ productRepo: productRepo, categoryRepo: categoryRepo, productVariantRepo: productVariantRepo, + currencyRepo: currencyRepo, } } +// CurrencyPriceInput represents a price in a specific currency +type CurrencyPriceInput struct { + CurrencyCode string `json:"currency_code"` + Price float64 `json:"price"` // Price in dollars/currency unit + ComparePrice float64 `json:"compare_price"` // Compare price in dollars/currency unit +} + // CreateProductInput contains the data needed to create a product (prices in dollars) type CreateProductInput struct { - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` // Price in dollars - Stock int `json:"stock"` - Weight float64 `json:"weight"` - CategoryID uint `json:"category_id"` - SellerID uint `json:"seller_id"` - Images []string `json:"images"` - HasVariants bool `json:"has_variants"` - Variants []CreateVariantInput `json:"variants"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` // Price in dollars (default currency) + ComparePrice float64 `json:"compare_price"` // Compare price in dollars (default currency) + Stock int `json:"stock"` + Weight float64 `json:"weight"` + CategoryID uint `json:"category_id"` + SellerID uint `json:"seller_id"` + Images []string `json:"images"` + HasVariants bool `json:"has_variants"` + CurrencyPrices []CurrencyPriceInput `json:"currency_prices"` // Prices in other currencies + Variants []CreateVariantInput `json:"variants"` } // CreateVariantInput contains the data needed to create a product variant (prices in dollars) type CreateVariantInput struct { - SKU string `json:"sku"` - Price float64 `json:"price"` // Price in dollars - ComparePrice float64 `json:"compare_price"` // Price in dollars - Stock int `json:"stock"` - Weight float64 `json:"weight"` - Attributes []entity.VariantAttribute `json:"attributes"` - Images []string `json:"images"` - IsDefault bool `json:"is_default"` + SKU string `json:"sku"` + Price float64 `json:"price"` // Price in dollars (default currency) + ComparePrice float64 `json:"compare_price"` // Price in dollars (default currency) + Stock int `json:"stock"` + Attributes []entity.VariantAttribute `json:"attributes"` + Images []string `json:"images"` + IsDefault bool `json:"is_default"` + CurrencyPrices []CurrencyPriceInput `json:"currency_prices"` // Prices in other currencies } // CreateProduct creates a new product @@ -83,6 +95,29 @@ func (uc *ProductUseCase) CreateProduct(input CreateProductInput) (*entity.Produ // Set has_variants flag product.HasVariants = input.HasVariants + // Process currency-specific prices, if any + if len(input.CurrencyPrices) > 0 { + product.Prices = make([]entity.ProductPrice, 0, len(input.CurrencyPrices)) + + for _, currPrice := range input.CurrencyPrices { + // Validate currency exists + _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) + if err != nil { + return nil, errors.New("invalid currency code: " + currPrice.CurrencyCode) + } + + // Convert price to cents + priceCents := money.ToCents(currPrice.Price) + comparePriceCents := money.ToCents(currPrice.ComparePrice) + + product.Prices = append(product.Prices, entity.ProductPrice{ + CurrencyCode: currPrice.CurrencyCode, + Price: priceCents, + ComparePrice: comparePriceCents, + }) + } + } + // Save product if err := uc.productRepo.Create(product); err != nil { return nil, err @@ -102,7 +137,6 @@ func (uc *ProductUseCase) CreateProduct(input CreateProductInput) (*entity.Produ variantInput.SKU, variantPriceCents, // Use cents variantInput.Stock, - variantInput.Weight, variantInput.Attributes, variantInput.Images, variantInput.IsDefault, @@ -117,12 +151,37 @@ func (uc *ProductUseCase) CreateProduct(input CreateProductInput) (*entity.Produ } } + // Process currency-specific prices for variant, if any + if len(variantInput.CurrencyPrices) > 0 { + variant.Prices = make([]entity.ProductVariantPrice, 0, len(variantInput.CurrencyPrices)) + + for _, currPrice := range variantInput.CurrencyPrices { + // Validate currency exists + _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) + if err != nil { + return nil, errors.New("invalid currency code: " + currPrice.CurrencyCode) + } + + // Convert price to cents + priceCents := money.ToCents(currPrice.Price) + comparePriceCents := money.ToCents(currPrice.ComparePrice) + + variant.Prices = append(variant.Prices, entity.ProductVariantPrice{ + CurrencyCode: currPrice.CurrencyCode, + Price: priceCents, + ComparePrice: comparePriceCents, + }) + } + } + variants = append(variants, variant) } - // Save variants in batch - if err := uc.productVariantRepo.BatchCreate(variants); err != nil { - return nil, err + // Save each variant individually to process their currency prices too + for _, variant := range variants { + if err := uc.productVariantRepo.Create(variant); err != nil { + return nil, err + } } // Add variants to product @@ -138,14 +197,55 @@ func (uc *ProductUseCase) GetProductByID(id uint) (*entity.Product, error) { return uc.productRepo.GetByIDWithVariants(id) } +// GetProductByCurrency retrieves a product by ID with prices in a specific currency +func (uc *ProductUseCase) GetProductByCurrency(id uint, currencyCode string) (*entity.Product, error) { + // First get the product with all its data + product, err := uc.productRepo.GetByIDWithVariants(id) + if err != nil { + return nil, err + } + + // If no specific currency requested, return as is + if currencyCode == "" { + defaultCurr, err := uc.currencyRepo.GetDefault() + if err != nil { + return nil, err + } + + currencyCode = defaultCurr.Code + } + + // Validate currency exists + currency, err := uc.currencyRepo.GetByCode(currencyCode) + if err != nil { + return nil, errors.New("invalid currency code: " + currencyCode) + } + + currencyPrice, found := product.GetPriceInCurrency(currency.Code) + if !found { + return nil, errors.New("product not available in the requested currency") + } + + // comparePrice, found := product.GetComparePriceInCurrency(currency.Code) + // if found { + // product.ComparePrice = comparePrice + // } + + product.Price = currencyPrice + + return product, nil +} + // UpdateProductInput contains the data needed to update a product (prices in dollars) type UpdateProductInput struct { - Name string `json:"name"` - Description string `json:"description"` - Price float64 `json:"price"` // Price in dollars - Stock int `json:"stock"` - CategoryID uint `json:"category_id"` - Images []string `json:"images"` + Name string `json:"name"` + Description string `json:"description"` + Price float64 `json:"price"` // Price in dollars (default currency) + ComparePrice float64 `json:"compare_price"` // Compare price in dollars (default currency) + Stock int `json:"stock"` + CategoryID uint `json:"category_id"` + Images []string `json:"images"` + CurrencyPrices []CurrencyPriceInput `json:"currency_prices"` // Prices in other currencies } // UpdateProduct updates a product @@ -187,6 +287,31 @@ func (uc *ProductUseCase) UpdateProduct(id uint, sellerID uint, input UpdateProd product.Images = input.Images } + // Process currency-specific prices, if any + if len(input.CurrencyPrices) > 0 { + // Clear existing prices + product.Prices = make([]entity.ProductPrice, 0, len(input.CurrencyPrices)) + + for _, currPrice := range input.CurrencyPrices { + // Validate currency exists + _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) + if err != nil { + return nil, errors.New("invalid currency code: " + currPrice.CurrencyCode) + } + + // Convert price to cents + priceCents := money.ToCents(currPrice.Price) + comparePriceCents := money.ToCents(currPrice.ComparePrice) + + product.Prices = append(product.Prices, entity.ProductPrice{ + ProductID: product.ID, + CurrencyCode: currPrice.CurrencyCode, + Price: priceCents, + ComparePrice: comparePriceCents, + }) + } + } + // Update product in repository if err := uc.productRepo.Update(product); err != nil { return nil, err @@ -206,13 +331,14 @@ func (uc *ProductUseCase) UpdateProduct(id uint, sellerID uint, input UpdateProd // UpdateVariantInput contains the data needed to update a product variant (prices in dollars) type UpdateVariantInput struct { - SKU string `json:"sku"` - Price float64 `json:"price"` // Price in dollars - ComparePrice float64 `json:"compare_price"` // Price in dollars - Stock int `json:"stock"` - Attributes []entity.VariantAttribute `json:"attributes"` - Images []string `json:"images"` - IsDefault bool `json:"is_default"` + SKU string `json:"sku"` + Price float64 `json:"price"` // Price in dollars + ComparePrice float64 `json:"compare_price"` // Price in dollars + Stock int `json:"stock"` + Attributes []entity.VariantAttribute `json:"attributes"` + Images []string `json:"images"` + IsDefault bool `json:"is_default"` + CurrencyPrices []CurrencyPriceInput `json:"currency_prices"` // Prices in other currencies } // UpdateVariant updates a product variant @@ -259,6 +385,31 @@ func (uc *ProductUseCase) UpdateVariant(productID uint, variantID uint, sellerID variant.Images = input.Images } + // Process currency-specific prices, if any + if len(input.CurrencyPrices) > 0 { + // Clear existing prices + variant.Prices = make([]entity.ProductVariantPrice, 0, len(input.CurrencyPrices)) + + for _, currPrice := range input.CurrencyPrices { + // Validate currency exists + _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) + if err != nil { + return nil, errors.New("invalid currency code: " + currPrice.CurrencyCode) + } + + // Convert price to cents + priceCents := money.ToCents(currPrice.Price) + comparePriceCents := money.ToCents(currPrice.ComparePrice) + + variant.Prices = append(variant.Prices, entity.ProductVariantPrice{ + VariantID: variant.ID, + CurrencyCode: currPrice.CurrencyCode, + Price: priceCents, + ComparePrice: comparePriceCents, + }) + } + } + // Handle default status if input.IsDefault != variant.IsDefault { // If setting this variant as default, unset any other default variants @@ -299,15 +450,15 @@ func (uc *ProductUseCase) UpdateVariant(productID uint, variantID uint, sellerID // AddVariantInput contains the data needed to add a variant to a product (prices in dollars) type AddVariantInput struct { - ProductID uint `json:"product_id"` - SKU string `json:"sku"` - Price float64 `json:"price"` // Price in dollars - ComparePrice float64 `json:"compare_price"` // Price in dollars - Stock int `json:"stock"` - Weight float64 `json:"weight"` - Attributes []entity.VariantAttribute `json:"attributes"` - Images []string `json:"images"` - IsDefault bool `json:"is_default"` + ProductID uint `json:"product_id"` + SKU string `json:"sku"` + Price float64 `json:"price"` // Price in dollars + ComparePrice float64 `json:"compare_price"` // Price in dollars + Stock int `json:"stock"` + Attributes []entity.VariantAttribute `json:"attributes"` + Images []string `json:"images"` + IsDefault bool `json:"is_default"` + CurrencyPrices []CurrencyPriceInput `json:"currency_prices"` // Prices in other currencies } // AddVariant adds a new variant to a product @@ -333,7 +484,6 @@ func (uc *ProductUseCase) AddVariant(sellerID uint, input AddVariantInput) (*ent input.SKU, priceCents, // Use cents input.Stock, - input.Weight, input.Attributes, input.Images, input.IsDefault, @@ -348,6 +498,29 @@ func (uc *ProductUseCase) AddVariant(sellerID uint, input AddVariantInput) (*ent } } + // Process currency-specific prices, if any + if len(input.CurrencyPrices) > 0 { + variant.Prices = make([]entity.ProductVariantPrice, 0, len(input.CurrencyPrices)) + + for _, currPrice := range input.CurrencyPrices { + // Validate currency exists + _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) + if err != nil { + return nil, errors.New("invalid currency code: " + currPrice.CurrencyCode) + } + + // Convert price to cents + priceCents := money.ToCents(currPrice.Price) + comparePriceCents := money.ToCents(currPrice.ComparePrice) + + variant.Prices = append(variant.Prices, entity.ProductVariantPrice{ + CurrencyCode: currPrice.CurrencyCode, + Price: priceCents, + ComparePrice: comparePriceCents, + }) + } + } + // If this is the first variant or it's set as default, update product isFirstVariant := !product.HasVariants if isFirstVariant || input.IsDefault { @@ -358,8 +531,7 @@ func (uc *ProductUseCase) AddVariant(sellerID uint, input AddVariantInput) (*ent // If this is the default variant, update product price and weight if input.IsDefault { - product.Price = priceCents // Update product price with cents - product.Weight = input.Weight // Also update product weight from variant + product.Price = priceCents // Update product price with cents // If there are other variants, unset their default status if !isFirstVariant { @@ -472,19 +644,44 @@ func (uc *ProductUseCase) DeleteProduct(id uint, sellerID uint) error { // SearchProductsInput contains the data needed to search for products (prices in dollars) type SearchProductsInput struct { - Query string `json:"query"` - CategoryID uint `json:"category_id"` - MinPrice float64 `json:"min_price"` // Price in dollars - MaxPrice float64 `json:"max_price"` // Price in dollars - Offset int `json:"offset"` - Limit int `json:"limit"` + Query string `json:"query"` + CategoryID uint `json:"category_id"` + MinPrice float64 `json:"min_price"` // Price in dollars + MaxPrice float64 `json:"max_price"` // Price in dollars + CurrencyCode string `json:"currency_code"` // Optional currency code for prices + Offset int `json:"offset"` + Limit int `json:"limit"` } // SearchProducts searches for products based on criteria func (uc *ProductUseCase) SearchProducts(input SearchProductsInput) ([]*entity.Product, error) { - // Convert min/max prices to cents for repository search - minPriceCents := money.ToCents(input.MinPrice) - maxPriceCents := money.ToCents(input.MaxPrice) + // If currency is specified and not the default, convert price ranges + var minPriceCents, maxPriceCents int64 + + // TODO: Default currency should be in memory + defaultCurr, err := uc.currencyRepo.GetDefault() + if err != nil { + return nil, err + } + + if input.CurrencyCode != "" && input.CurrencyCode != defaultCurr.Code { + // Get the currency + currency, err := uc.currencyRepo.GetByCode(input.CurrencyCode) + if err != nil { + return nil, errors.New("invalid currency code: " + input.CurrencyCode) + } + + // Convert min/max prices to default currency using exchange rate + defaultPrice := input.MinPrice / currency.ExchangeRate + minPriceCents = money.ToCents(defaultPrice) + + defaultPrice = input.MaxPrice / currency.ExchangeRate + maxPriceCents = money.ToCents(defaultPrice) + } else { + // Convert min/max prices to cents for repository search + minPriceCents = money.ToCents(input.MinPrice) + maxPriceCents = money.ToCents(input.MaxPrice) + } return uc.productRepo.Search( input.Query, @@ -501,13 +698,103 @@ func (uc *ProductUseCase) ListProductsBySeller(sellerID uint, offset, limit int) return uc.productRepo.GetBySeller(sellerID, offset, limit) } +// ListProducts lists all products with pagination func (uc *ProductUseCase) ListProducts(offset, limit int) ([]*entity.Product, error) { return uc.productRepo.List(offset, limit) } -// ListProductsByCategory lists products by category +// ListCategories lists all product categories func (uc *ProductUseCase) ListCategories() ([]*entity.Category, error) { return uc.categoryRepo.List() } -// ListProductsByCategoryAndSeller lists products by category and seller +// SetProductCurrencyPrices sets currency-specific prices for a product +func (uc *ProductUseCase) SetProductCurrencyPrices(productID uint, sellerID uint, currencyPrices []CurrencyPriceInput) error { + // Get product to check ownership + product, err := uc.productRepo.GetByID(productID) + if err != nil { + return err + } + + // Check if user is the seller of the product + if product.SellerID != sellerID { + return errors.New("unauthorized: not the seller of this product") + } + + // Clear existing currency prices + product.Prices = make([]entity.ProductPrice, 0, len(currencyPrices)) + + // Add new currency prices + for _, currPrice := range currencyPrices { + // Validate currency exists + _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) + if err != nil { + return errors.New("invalid currency code: " + currPrice.CurrencyCode) + } + + // Convert prices to cents + priceCents := money.ToCents(currPrice.Price) + comparePriceCents := money.ToCents(currPrice.ComparePrice) + + product.Prices = append(product.Prices, entity.ProductPrice{ + ProductID: productID, + CurrencyCode: currPrice.CurrencyCode, + Price: priceCents, + ComparePrice: comparePriceCents, + }) + } + + // Update product in repository + return uc.productRepo.Update(product) +} + +// SetVariantCurrencyPrices sets currency-specific prices for a product variant +func (uc *ProductUseCase) SetVariantCurrencyPrices(productID uint, variantID uint, sellerID uint, currencyPrices []CurrencyPriceInput) error { + // Get product to check ownership + product, err := uc.productRepo.GetByID(productID) + if err != nil { + return err + } + + // Check if user is the seller of the product + if product.SellerID != sellerID { + return errors.New("unauthorized: not the seller of this product") + } + + // Get variant + variant, err := uc.productVariantRepo.GetByID(variantID) + if err != nil { + return err + } + + // Check if variant belongs to the product + if variant.ProductID != productID { + return errors.New("variant does not belong to this product") + } + + // Clear existing currency prices + variant.Prices = make([]entity.ProductVariantPrice, 0, len(currencyPrices)) + + // Add new currency prices + for _, currPrice := range currencyPrices { + // Validate currency exists + _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) + if err != nil { + return errors.New("invalid currency code: " + currPrice.CurrencyCode) + } + + // Convert prices to cents + priceCents := money.ToCents(currPrice.Price) + comparePriceCents := money.ToCents(currPrice.ComparePrice) + + variant.Prices = append(variant.Prices, entity.ProductVariantPrice{ + VariantID: variantID, + CurrencyCode: currPrice.CurrencyCode, + Price: priceCents, + ComparePrice: comparePriceCents, + }) + } + + // Update variant in repository + return uc.productVariantRepo.Update(variant) +} diff --git a/internal/application/usecase/product_usecase_test.go b/internal/application/usecase/product_usecase_test.go index add648e..55c7142 100644 --- a/internal/application/usecase/product_usecase_test.go +++ b/internal/application/usecase/product_usecase_test.go @@ -16,6 +16,7 @@ func TestProductUseCase_CreateProduct(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test category category := &entity.Category{ @@ -29,6 +30,7 @@ func TestProductUseCase_CreateProduct(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Create product input @@ -65,6 +67,7 @@ func TestProductUseCase_CreateProduct(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test category category := &entity.Category{ @@ -78,6 +81,7 @@ func TestProductUseCase_CreateProduct(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Create product input with variants @@ -133,12 +137,14 @@ func TestProductUseCase_CreateProduct(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create use case with mocks productUseCase := usecase.NewProductUseCase( productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Create product input with invalid category @@ -169,6 +175,7 @@ func TestProductUseCase_GetProductByID(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test product product := &entity.Product{ @@ -189,6 +196,7 @@ func TestProductUseCase_GetProductByID(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Execute @@ -206,12 +214,14 @@ func TestProductUseCase_GetProductByID(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create use case with mocks productUseCase := usecase.NewProductUseCase( productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Execute with non-existent ID @@ -229,6 +239,7 @@ func TestProductUseCase_UpdateProduct(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create test category and product category := &entity.Category{ @@ -261,6 +272,7 @@ func TestProductUseCase_UpdateProduct(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Update input @@ -291,6 +303,7 @@ func TestProductUseCase_UpdateProduct(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test product product := &entity.Product{ @@ -311,6 +324,7 @@ func TestProductUseCase_UpdateProduct(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Update input with different seller @@ -334,6 +348,7 @@ func TestProductUseCase_AddVariant(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test product without variants product := &entity.Product{ @@ -354,6 +369,7 @@ func TestProductUseCase_AddVariant(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Add variant input @@ -396,6 +412,7 @@ func TestProductUseCase_UpdateVariant(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test product with variants product := &entity.Product{ @@ -445,6 +462,7 @@ func TestProductUseCase_UpdateVariant(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Update variant input @@ -487,6 +505,7 @@ func TestProductUseCase_DeleteVariant(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test product with variants product := &entity.Product{ @@ -536,6 +555,7 @@ func TestProductUseCase_DeleteVariant(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Execute - delete the non-default variant @@ -560,6 +580,7 @@ func TestProductUseCase_DeleteVariant(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test product with variants product := &entity.Product{ @@ -609,6 +630,7 @@ func TestProductUseCase_DeleteVariant(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Execute - delete the default variant @@ -632,6 +654,7 @@ func TestProductUseCase_DeleteVariant(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test product with one variant product := &entity.Product{ @@ -667,6 +690,7 @@ func TestProductUseCase_DeleteVariant(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Execute - try to delete the only variant @@ -684,6 +708,7 @@ func TestProductUseCase_SearchProducts(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create test products product1 := &entity.Product{ @@ -760,6 +785,7 @@ func TestProductUseCase_SearchProducts(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Search by shirt @@ -817,6 +843,7 @@ func TestProductUseCase_DeleteProduct(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test product product := &entity.Product{ @@ -837,6 +864,7 @@ func TestProductUseCase_DeleteProduct(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Execute @@ -856,6 +884,7 @@ func TestProductUseCase_DeleteProduct(t *testing.T) { productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() // Create a test product product := &entity.Product{ @@ -876,6 +905,7 @@ func TestProductUseCase_DeleteProduct(t *testing.T) { productRepo, categoryRepo, productVariantRepo, + currencyRepo, ) // Execute with different seller ID diff --git a/internal/domain/entity/currency.go b/internal/domain/entity/currency.go new file mode 100644 index 0000000..67ed15c --- /dev/null +++ b/internal/domain/entity/currency.go @@ -0,0 +1,128 @@ +package entity + +import ( + "errors" + "strings" + "time" +) + +// Currency represents a currency in the system +type Currency struct { + Code string `json:"code"` + Name string `json:"name"` + Symbol string `json:"symbol"` + ExchangeRate float64 `json:"exchange_rate"` + IsEnabled bool `json:"is_enabled"` + IsDefault bool `json:"is_default"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ProductPrice represents a price for a product in a specific currency +type ProductPrice struct { + ID uint `json:"id"` + ProductID uint `json:"product_id"` + CurrencyCode string `json:"currency_code"` + Price int64 `json:"price"` // Price in cents + ComparePrice int64 `json:"compare_price"` // Compare price in cents + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ProductVariantPrice represents a price for a product variant in a specific currency +type ProductVariantPrice struct { + ID uint `json:"id"` + VariantID uint `json:"variant_id"` + CurrencyCode string `json:"currency_code"` + Price int64 `json:"price"` // Price in cents + ComparePrice int64 `json:"compare_price"` // Compare price in cents + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// NewCurrency creates a new Currency +func NewCurrency(code, name, symbol string, exchangeRate float64, isEnabled bool, isDefault bool) (*Currency, error) { + // Validate required fields + if strings.TrimSpace(code) == "" { + return nil, errors.New("currency code is required") + } + + if strings.TrimSpace(name) == "" { + return nil, errors.New("currency name is required") + } + + if strings.TrimSpace(symbol) == "" { + return nil, errors.New("currency symbol is required") + } + + if exchangeRate <= 0 { + return nil, errors.New("exchange rate must be positive") + } + + now := time.Now() + return &Currency{ + Code: strings.ToUpper(code), + Name: name, + Symbol: symbol, + ExchangeRate: exchangeRate, + IsEnabled: isEnabled, + IsDefault: isDefault, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +// SetExchangeRate sets the exchange rate for the currency +func (c *Currency) SetExchangeRate(rate float64) error { + if rate <= 0 { + return errors.New("exchange rate must be positive") + } + c.ExchangeRate = rate + c.UpdatedAt = time.Now() + return nil +} + +// Enable enables the currency +func (c *Currency) Enable() { + c.IsEnabled = true + c.UpdatedAt = time.Now() +} + +// Disable disables the currency +func (c *Currency) Disable() error { + if c.IsDefault { + return errors.New("cannot disable the default currency") + } + c.IsEnabled = false + c.UpdatedAt = time.Now() + return nil +} + +// SetAsDefault sets this currency as the default currency +func (c *Currency) SetAsDefault() { + c.IsDefault = true + c.IsEnabled = true // Default currency must be enabled + c.UpdatedAt = time.Now() +} + +// UnsetAsDefault unsets this currency as the default currency +func (c *Currency) UnsetAsDefault() error { + c.IsDefault = false + c.UpdatedAt = time.Now() + return nil +} + +// ConvertAmount converts an amount from this currency to the target currency +func (c *Currency) ConvertAmount(amount int64, targetCurrency *Currency) int64 { + if c.Code == targetCurrency.Code { + return amount + } + + // First convert to a base unit (like USD) + baseAmount := float64(amount) / c.ExchangeRate + + // Then convert to target currency + targetAmount := baseAmount * targetCurrency.ExchangeRate + + return int64(targetAmount) +} diff --git a/internal/domain/entity/product.go b/internal/domain/entity/product.go index a5e8e5d..ebd0681 100644 --- a/internal/domain/entity/product.go +++ b/internal/domain/entity/product.go @@ -14,7 +14,7 @@ type Product struct { ProductNumber string `json:"product_number"` Name string `json:"name"` Description string `json:"description"` - Price int64 `json:"price"` // Stored as cents + Price int64 `json:"price"` // Stored as cents (in default currency) Stock int `json:"stock"` Weight float64 `json:"weight"` // Weight in kg CategoryID uint `json:"category_id"` @@ -22,6 +22,7 @@ type Product struct { Images []string `json:"images"` HasVariants bool `json:"has_variants"` Variants []*ProductVariant `json:"variants,omitempty"` + Prices []ProductPrice `json:"prices,omitempty"` // Prices in different currencies CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -68,6 +69,7 @@ func (p *Product) UpdateStock(quantity int) error { if newStock < 0 { return errors.New("insufficient stock") } + p.Stock = newStock p.UpdatedAt = time.Now() return nil @@ -173,6 +175,42 @@ func (p *Product) GetPriceDollars() float64 { return money.FromCents(p.Price) } +// GetPriceInCurrency returns the price for a specific currency +func (p *Product) GetPriceInCurrency(currencyCode string) (int64, bool) { + // If no currency specified or matches default currency, return base price + if currencyCode == "" { + return p.Price, true + } + + // Look for price in the specified currency + for _, price := range p.Prices { + if price.CurrencyCode == currencyCode { + return price.Price, true + } + } + + // Currency price not found + return 0, false +} + +// GetComparePriceInCurrency returns the compare price for a specific currency +func (p *Product) GetComparePriceInCurrency(currencyCode string) (int64, bool) { + // If no currency specified, return base compare price + if currencyCode == "" { + return 0, false + } + + // Look for price in the specified currency + for _, price := range p.Prices { + if price.CurrencyCode == currencyCode { + return price.ComparePrice, price.ComparePrice > 0 + } + } + + // Currency compare price not found + return 0, false +} + // Category represents a product category type Category struct { ID uint `json:"id"` diff --git a/internal/domain/entity/product_variant.go b/internal/domain/entity/product_variant.go index 7f7e4f6..b28a69d 100644 --- a/internal/domain/entity/product_variant.go +++ b/internal/domain/entity/product_variant.go @@ -15,21 +15,22 @@ type VariantAttribute struct { // ProductVariant represents a specific variant of a product type ProductVariant struct { - ID uint `json:"id"` - ProductID uint `json:"product_id"` - SKU string `json:"sku"` - Price int64 `json:"price"` // Stored as cents - ComparePrice int64 `json:"compare_price,omitempty"` // Stored as cents - Stock int `json:"stock"` - Attributes []VariantAttribute `json:"attributes"` - Images []string `json:"images,omitempty"` - IsDefault bool `json:"is_default"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + ProductID uint `json:"product_id"` + SKU string `json:"sku"` + Price int64 `json:"price"` // Stored as cents (in default currency) + ComparePrice int64 `json:"compare_price,omitempty"` // Stored as cents (in default currency) + Stock int `json:"stock"` + Attributes []VariantAttribute `json:"attributes"` + Images []string `json:"images,omitempty"` + IsDefault bool `json:"is_default"` + Prices []ProductVariantPrice `json:"prices,omitempty"` // Prices in different currencies + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // NewProductVariant creates a new product variant -func NewProductVariant(productID uint, sku string, price int64, stock int, weight float64, attributes []VariantAttribute, images []string, isDefault bool) (*ProductVariant, error) { +func NewProductVariant(productID uint, sku string, price int64, stock int, attributes []VariantAttribute, images []string, isDefault bool) (*ProductVariant, error) { if productID == 0 { return nil, errors.New("product ID cannot be empty") } @@ -42,9 +43,6 @@ func NewProductVariant(productID uint, sku string, price int64, stock int, weigh if stock < 0 { return nil, errors.New("stock cannot be negative") } - if weight < 0 { - return nil, errors.New("weight cannot be negative") - } if len(attributes) == 0 { return nil, errors.New("variant must have at least one attribute") } @@ -91,14 +89,6 @@ func (v *ProductVariant) IsAvailable(quantity int) bool { return v.Stock >= quantity } -// GetTotalWeight calculates the total weight for a quantity of this variant -// func (v *ProductVariant) GetTotalWeight(quantity int) float64 { -// if quantity <= 0 { -// return 0 -// } -// return v.Weight * float64(quantity) -// } - // GetPriceDollars returns the price in dollars func (v *ProductVariant) GetPriceDollars() float64 { return money.FromCents(v.Price) @@ -108,3 +98,39 @@ func (v *ProductVariant) GetPriceDollars() float64 { func (v *ProductVariant) GetComparePriceDollars() float64 { return money.FromCents(v.ComparePrice) } + +// GetPriceInCurrency returns the price for a specific currency +func (v *ProductVariant) GetPriceInCurrency(currencyCode string) (int64, bool) { + // If no currency specified or matches default currency, return base price + if currencyCode == "" { + return v.Price, true + } + + // Look for price in the specified currency + for _, price := range v.Prices { + if price.CurrencyCode == currencyCode { + return price.Price, true + } + } + + // Currency price not found + return 0, false +} + +// GetComparePriceInCurrency returns the compare price for a specific currency +func (v *ProductVariant) GetComparePriceInCurrency(currencyCode string) (int64, bool) { + // If no currency specified, return base compare price + if currencyCode == "" { + return v.ComparePrice, v.ComparePrice > 0 + } + + // Look for price in the specified currency + for _, price := range v.Prices { + if price.CurrencyCode == currencyCode { + return price.ComparePrice, price.ComparePrice > 0 + } + } + + // Currency compare price not found + return 0, false +} diff --git a/internal/domain/error/products.go b/internal/domain/error/products.go new file mode 100644 index 0000000..0ac1bf2 --- /dev/null +++ b/internal/domain/error/products.go @@ -0,0 +1,5 @@ +package errors + +const ( + ProductNotFoundError = "Product not found" +) diff --git a/internal/domain/repository/currency_repository.go b/internal/domain/repository/currency_repository.go new file mode 100644 index 0000000..42b97e1 --- /dev/null +++ b/internal/domain/repository/currency_repository.go @@ -0,0 +1,28 @@ +package repository + +import "github.com/zenfulcode/commercify/internal/domain/entity" + +// CurrencyRepository defines the contract for currency operations +type CurrencyRepository interface { + // Currency operations + Create(currency *entity.Currency) error + Update(currency *entity.Currency) error + Delete(code string) error + GetByCode(code string) (*entity.Currency, error) + GetDefault() (*entity.Currency, error) + List() ([]*entity.Currency, error) + ListEnabled() ([]*entity.Currency, error) + SetDefault(code string) error + + // Product price operations + GetProductPrices(productID uint) ([]entity.ProductPrice, error) + // SetProductPrices(productID uint, prices []entity.ProductPrice) error + DeleteProductPrice(productID uint, currencyCode string) error + // SetProductPrice(price *entity.ProductPrice) error + + // Product variant price operations + GetVariantPrices(variantID uint) ([]entity.ProductVariantPrice, error) + // SetVariantPrices(variantID uint, prices []entity.ProductVariantPrice) error + // SetVariantPrice(prices *entity.ProductVariantPrice) error + DeleteVariantPrice(variantID uint, currencyCode string) error +} diff --git a/internal/infrastructure/container/container.go b/internal/infrastructure/container/container.go index 425a1bf..e08f68c 100644 --- a/internal/infrastructure/container/container.go +++ b/internal/infrastructure/container/container.go @@ -3,7 +3,6 @@ package container import ( "database/sql" - "sync" "github.com/zenfulcode/commercify/config" "github.com/zenfulcode/commercify/internal/infrastructure/logger" @@ -48,9 +47,6 @@ type DIContainer struct { useCases UseCaseProvider handlers HandlerProvider middlewares MiddlewareProvider - - // Mutex for thread-safe initialization - mu sync.Mutex } // NewContainer creates a new dependency injection container diff --git a/internal/infrastructure/container/handler_provider.go b/internal/infrastructure/container/handler_provider.go index 4850a8e..30d07d5 100644 --- a/internal/infrastructure/container/handler_provider.go +++ b/internal/infrastructure/container/handler_provider.go @@ -16,6 +16,7 @@ type HandlerProvider interface { WebhookHandler() *handler.WebhookHandler DiscountHandler() *handler.DiscountHandler ShippingHandler() *handler.ShippingHandler + CurrencyHandler() *handler.CurrencyHandler } // handlerProvider is the concrete implementation of HandlerProvider @@ -31,6 +32,7 @@ type handlerProvider struct { webhookHandler *handler.WebhookHandler discountHandler *handler.DiscountHandler shippingHandler *handler.ShippingHandler + currencyHandler *handler.CurrencyHandler } // NewHandlerProvider creates a new handler provider @@ -155,3 +157,18 @@ func (p *handlerProvider) ShippingHandler() *handler.ShippingHandler { } return p.shippingHandler } + +// CurrencyHandler returns the currency handler +func (p *handlerProvider) CurrencyHandler() *handler.CurrencyHandler { + p.mu.Lock() + defer p.mu.Unlock() + + if p.currencyHandler == nil { + // Check if CurrencyUseCase exists in the UseCaseProvider + p.currencyHandler = handler.NewCurrencyHandler( + p.container.UseCases().CurrencyUsecase(), + p.container.Logger(), + ) + } + return p.currencyHandler +} diff --git a/internal/infrastructure/container/repository_provider.go b/internal/infrastructure/container/repository_provider.go index ccd1037..b781ff1 100644 --- a/internal/infrastructure/container/repository_provider.go +++ b/internal/infrastructure/container/repository_provider.go @@ -18,6 +18,7 @@ type RepositoryProvider interface { DiscountRepository() repository.DiscountRepository WebhookRepository() repository.WebhookRepository PaymentTransactionRepository() repository.PaymentTransactionRepository + CurrencyRepository() repository.CurrencyRepository // Shipping related repository ShippingMethodRepository() repository.ShippingMethodRepository @@ -39,6 +40,7 @@ type repositoryProvider struct { discountRepo repository.DiscountRepository webhookRepo repository.WebhookRepository paymentTrxRepo repository.PaymentTransactionRepository + currencyRepo repository.CurrencyRepository shippingMethodRepo repository.ShippingMethodRepository shippingZoneRepo repository.ShippingZoneRepository @@ -183,3 +185,14 @@ func (p *repositoryProvider) ShippingRateRepository() repository.ShippingRateRep } return p.shippingRateRepo } + +// CurrencyRepository returns the currency repository +func (p *repositoryProvider) CurrencyRepository() repository.CurrencyRepository { + p.mu.Lock() + defer p.mu.Unlock() + + if p.currencyRepo == nil { + p.currencyRepo = postgres.NewCurrencyRepository(p.container.DB()) + } + return p.currencyRepo +} diff --git a/internal/infrastructure/container/usecase_provider.go b/internal/infrastructure/container/usecase_provider.go index 3dc3a5c..2c04765 100644 --- a/internal/infrastructure/container/usecase_provider.go +++ b/internal/infrastructure/container/usecase_provider.go @@ -15,6 +15,7 @@ type UseCaseProvider interface { DiscountUseCase() *usecase.DiscountUseCase WebhookUseCase() *usecase.WebhookUseCase ShippingUseCase() *usecase.ShippingUseCase + CurrencyUsecase() *usecase.CurrencyUseCase } // useCaseProvider is the concrete implementation of UseCaseProvider @@ -29,6 +30,7 @@ type useCaseProvider struct { discountUseCase *usecase.DiscountUseCase webhookUseCase *usecase.WebhookUseCase shippingUseCase *usecase.ShippingUseCase + currencyUseCase *usecase.CurrencyUseCase } // NewUseCaseProvider creates a new use case provider @@ -61,6 +63,7 @@ func (p *useCaseProvider) ProductUseCase() *usecase.ProductUseCase { p.container.Repositories().ProductRepository(), p.container.Repositories().CategoryRepository(), p.container.Repositories().ProductVariantRepository(), + p.container.Repositories().CurrencyRepository(), ) } return p.productUseCase @@ -153,3 +156,27 @@ func (p *useCaseProvider) InitializeShippingUseCase() *usecase.ShippingUseCase { } return p.shippingUseCase } + +// CurrencyUsecase returns the currency use case +func (p *useCaseProvider) CurrencyUsecase() *usecase.CurrencyUseCase { + p.mu.Lock() + defer p.mu.Unlock() + + if p.currencyUseCase == nil { + p.currencyUseCase = usecase.NewCurrencyUseCase( + p.container.Repositories().CurrencyRepository(), + ) + + var defaultCurrency usecase.CurrencyInput = usecase.CurrencyInput{ + Code: p.container.Config().DefaultCurrency, + Name: "Default Currency", + Symbol: "$", + ExchangeRate: 1.0, + IsEnabled: true, + IsDefault: true, + } + + p.currencyUseCase.CreateCurrency(defaultCurrency) + } + return p.currencyUseCase +} diff --git a/internal/infrastructure/repository/postgres/currency_repository.go b/internal/infrastructure/repository/postgres/currency_repository.go new file mode 100644 index 0000000..032a3e7 --- /dev/null +++ b/internal/infrastructure/repository/postgres/currency_repository.go @@ -0,0 +1,461 @@ +package postgres + +import ( + "database/sql" + "errors" + "time" + + "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/domain/repository" +) + +// CurrencyRepository is the PostgreSQL implementation of the currency repository +type CurrencyRepository struct { + db *sql.DB +} + +// NewCurrencyRepository creates a new currency repository +func NewCurrencyRepository(db *sql.DB) repository.CurrencyRepository { + return &CurrencyRepository{ + db: db, + } +} + +// Create creates a new currency +func (r *CurrencyRepository) Create(currency *entity.Currency) error { + query := ` + INSERT INTO currencies (code, name, symbol, exchange_rate, is_default, is_enabled, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + symbol = EXCLUDED.symbol, + exchange_rate = EXCLUDED.exchange_rate, + is_default = EXCLUDED.is_default, + is_enabled = EXCLUDED.is_enabled, + updated_at = EXCLUDED.updated_at + ` + + _, err := r.db.Exec( + query, + currency.Code, + currency.Name, + currency.Symbol, + currency.ExchangeRate, + currency.IsDefault, + currency.IsEnabled, + currency.CreatedAt, + currency.UpdatedAt, + ) + + if err != nil { + return err + } + + // If this is the default currency, ensure it's the only default + if currency.IsDefault { + _, err = r.db.Exec( + "UPDATE currencies SET is_default = false WHERE code != $1", + currency.Code, + ) + if err != nil { + return err + } + } + + return nil +} + +// GetByCode retrieves a currency by its code +func (r *CurrencyRepository) GetByCode(code string) (*entity.Currency, error) { + query := ` + SELECT code, name, symbol, exchange_rate, is_default, is_enabled, created_at, updated_at + FROM currencies + WHERE code = $1 + ` + + var currency entity.Currency + err := r.db.QueryRow(query, code).Scan( + ¤cy.Code, + ¤cy.Name, + ¤cy.Symbol, + ¤cy.ExchangeRate, + ¤cy.IsDefault, + ¤cy.IsEnabled, + ¤cy.CreatedAt, + ¤cy.UpdatedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, errors.New("currency not found") + } + return nil, err + } + + return ¤cy, nil +} + +// GetDefault retrieves the default currency +func (r *CurrencyRepository) GetDefault() (*entity.Currency, error) { + query := ` + SELECT code, name, symbol, exchange_rate, is_default, is_enabled, created_at, updated_at + FROM currencies + WHERE is_default = true + LIMIT 1 + ` + + var currency entity.Currency + err := r.db.QueryRow(query).Scan( + ¤cy.Code, + ¤cy.Name, + ¤cy.Symbol, + ¤cy.ExchangeRate, + ¤cy.IsDefault, + ¤cy.IsEnabled, + ¤cy.CreatedAt, + ¤cy.UpdatedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, errors.New("no default currency found") + } + return nil, err + } + + return ¤cy, nil +} + +// List returns all currencies +func (r *CurrencyRepository) List() ([]*entity.Currency, error) { + query := ` + SELECT code, name, symbol, exchange_rate, is_default, is_enabled, created_at, updated_at + FROM currencies + ORDER BY is_default DESC, code ASC + ` + + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var currencies []*entity.Currency + for rows.Next() { + var currency entity.Currency + err := rows.Scan( + ¤cy.Code, + ¤cy.Name, + ¤cy.Symbol, + ¤cy.ExchangeRate, + ¤cy.IsDefault, + ¤cy.IsEnabled, + ¤cy.CreatedAt, + ¤cy.UpdatedAt, + ) + if err != nil { + return nil, err + } + currencies = append(currencies, ¤cy) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return currencies, nil +} + +// ListEnabled returns all enabled currencies +func (r *CurrencyRepository) ListEnabled() ([]*entity.Currency, error) { + query := ` + SELECT code, name, symbol, exchange_rate, is_default, is_enabled, created_at, updated_at + FROM currencies + WHERE is_enabled = true + ORDER BY is_default DESC, code ASC + ` + + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var currencies []*entity.Currency + for rows.Next() { + var currency entity.Currency + err := rows.Scan( + ¤cy.Code, + ¤cy.Name, + ¤cy.Symbol, + ¤cy.ExchangeRate, + ¤cy.IsDefault, + ¤cy.IsEnabled, + ¤cy.CreatedAt, + ¤cy.UpdatedAt, + ) + if err != nil { + return nil, err + } + currencies = append(currencies, ¤cy) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return currencies, nil +} + +// Update updates a currency +func (r *CurrencyRepository) Update(currency *entity.Currency) error { + query := ` + UPDATE currencies + SET name = $2, symbol = $3, exchange_rate = $4, is_default = $5, is_enabled = $6, updated_at = $7 + WHERE code = $1 + ` + + _, err := r.db.Exec( + query, + currency.Code, + currency.Name, + currency.Symbol, + currency.ExchangeRate, + currency.IsDefault, + currency.IsEnabled, + time.Now(), + ) + + if err != nil { + return err + } + + // If this is the default currency, ensure it's the only default + if currency.IsDefault { + _, err = r.db.Exec( + "UPDATE currencies SET is_default = false WHERE code != $1", + currency.Code, + ) + if err != nil { + return err + } + } + + return nil +} + +// Delete deletes a currency +func (r *CurrencyRepository) Delete(code string) error { + // Check if this is the default currency + var isDefault bool + err := r.db.QueryRow("SELECT is_default FROM currencies WHERE code = $1", code).Scan(&isDefault) + if err != nil { + return err + } + + if isDefault { + return errors.New("cannot delete default currency") + } + + query := "DELETE FROM currencies WHERE code = $1" + _, err = r.db.Exec(query, code) + return err +} + +// SetDefault sets a currency as the default +func (r *CurrencyRepository) SetDefault(code string) error { + // Start a transaction + tx, err := r.db.Begin() + if err != nil { + return err + } + + // First, set all currencies to not be default + _, err = tx.Exec("UPDATE currencies SET is_default = false") + if err != nil { + tx.Rollback() + return err + } + + // Then set the specified currency as default + _, err = tx.Exec("UPDATE currencies SET is_default = true WHERE code = $1", code) + if err != nil { + tx.Rollback() + return err + } + + // Commit the transaction + return tx.Commit() +} + +// GetProductPrices retrieves all prices for a product in different currencies +func (r *CurrencyRepository) GetProductPrices(productID uint) ([]entity.ProductPrice, error) { + query := ` + SELECT id, product_id, currency_code, price, compare_price, created_at, updated_at + FROM product_prices + WHERE product_id = $1 + ` + + rows, err := r.db.Query(query, productID) + if err != nil { + return nil, err + } + defer rows.Close() + + var prices []entity.ProductPrice + for rows.Next() { + var price entity.ProductPrice + var comparePrice sql.NullInt64 + + err := rows.Scan( + &price.ID, + &price.ProductID, + &price.CurrencyCode, + &price.Price, + &comparePrice, + &price.CreatedAt, + &price.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if comparePrice.Valid { + price.ComparePrice = comparePrice.Int64 + } + + prices = append(prices, price) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return prices, nil +} + +// SetProductPrice sets or updates a price for a product in a specific currency +func (r *CurrencyRepository) SetProductPrice(price *entity.ProductPrice) error { + query := ` + INSERT INTO product_prices (product_id, currency_code, price, compare_price, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (product_id, currency_code) DO UPDATE SET + price = EXCLUDED.price, + compare_price = EXCLUDED.compare_price, + updated_at = EXCLUDED.updated_at + RETURNING id + ` + + var comparePrice sql.NullInt64 + if price.ComparePrice > 0 { + comparePrice.Int64 = price.ComparePrice + comparePrice.Valid = true + } + + now := time.Now() + + err := r.db.QueryRow( + query, + price.ProductID, + price.CurrencyCode, + price.Price, + comparePrice, + now, + now, + ).Scan(&price.ID) + + return err +} + +// DeleteProductPrice removes a price for a product in a specific currency +func (r *CurrencyRepository) DeleteProductPrice(productID uint, currencyCode string) error { + query := "DELETE FROM product_prices WHERE product_id = $1 AND currency_code = $2" + _, err := r.db.Exec(query, productID, currencyCode) + return err +} + +// GetProductVariantPrices retrieves all prices for a product variant in different currencies +func (r *CurrencyRepository) GetVariantPrices(variantID uint) ([]entity.ProductVariantPrice, error) { + query := ` + SELECT id, variant_id, currency_code, price, compare_price, created_at, updated_at + FROM product_variant_prices + WHERE variant_id = $1 + ` + + rows, err := r.db.Query(query, variantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var prices []entity.ProductVariantPrice + for rows.Next() { + var price entity.ProductVariantPrice + var comparePrice sql.NullInt64 + + err := rows.Scan( + &price.ID, + &price.VariantID, + &price.CurrencyCode, + &price.Price, + &comparePrice, + &price.CreatedAt, + &price.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if comparePrice.Valid { + price.ComparePrice = comparePrice.Int64 + } + + prices = append(prices, price) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return prices, nil +} + +// SetProductVariantPrice sets or updates a price for a product variant in a specific currency +func (r *CurrencyRepository) SetVariantPrice(price *entity.ProductVariantPrice) error { + query := ` + INSERT INTO product_variant_prices (variant_id, currency_code, price, compare_price, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (variant_id, currency_code) DO UPDATE SET + price = EXCLUDED.price, + compare_price = EXCLUDED.compare_price, + updated_at = EXCLUDED.updated_at + RETURNING id + ` + + var comparePrice sql.NullInt64 + if price.ComparePrice > 0 { + comparePrice.Int64 = price.ComparePrice + comparePrice.Valid = true + } + + now := time.Now() + + err := r.db.QueryRow( + query, + price.VariantID, + price.CurrencyCode, + price.Price, + comparePrice, + now, + now, + ).Scan(&price.ID) + + return err +} + +// DeleteProductVariantPrice removes a price for a product variant in a specific currency +func (r *CurrencyRepository) DeleteVariantPrice(variantID uint, currencyCode string) error { + query := "DELETE FROM product_variant_prices WHERE variant_id = $1 AND currency_code = $2" + _, err := r.db.Exec(query, variantID, currencyCode) + return err +} diff --git a/internal/infrastructure/repository/postgres/product_repository.go b/internal/infrastructure/repository/postgres/product_repository.go index 2175f17..9918d4c 100644 --- a/internal/infrastructure/repository/postgres/product_repository.go +++ b/internal/infrastructure/repository/postgres/product_repository.go @@ -9,16 +9,19 @@ import ( "time" "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/domain/repository" ) -// ProductRepository implements the product repository interface using PostgreSQL +// ProductRepository is the PostgreSQL implementation of the ProductRepository interface type ProductRepository struct { db *sql.DB } // NewProductRepository creates a new ProductRepository -func NewProductRepository(db *sql.DB) *ProductRepository { - return &ProductRepository{db: db} +func NewProductRepository(db *sql.DB) repository.ProductRepository { + return &ProductRepository{ + db: db, + } } // Create creates a new product @@ -55,17 +58,58 @@ func (r *ProductRepository) Create(product *entity.Product) error { // Generate and set the product number product.SetProductNumber(product.ID) - // Update the product with the generated product number - _, err = r.db.Exec( - "UPDATE products SET product_number = $1 WHERE id = $2", - product.ProductNumber, - product.ID, - ) + // Update the product number in the database + updateQuery := "UPDATE products SET product_number = $1 WHERE id = $2" + _, err = r.db.Exec(updateQuery, product.ProductNumber, product.ID) + if err != nil { + return err + } + + // If the product has currency-specific prices, save them + if len(product.Prices) > 0 { + for i := range product.Prices { + product.Prices[i].ProductID = product.ID + if err = r.createProductPrice(&product.Prices[i]); err != nil { + return err + } + } + } - return err + return nil +} + +// createProductPrice creates a product price entry for a specific currency +func (r *ProductRepository) createProductPrice(price *entity.ProductPrice) error { + query := ` + INSERT INTO product_prices (product_id, currency_code, price, compare_price, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (product_id, currency_code) DO UPDATE SET + price = EXCLUDED.price, + compare_price = EXCLUDED.compare_price, + updated_at = EXCLUDED.updated_at + RETURNING id + ` + + var comparePrice sql.NullInt64 + if price.ComparePrice > 0 { + comparePrice.Int64 = price.ComparePrice + comparePrice.Valid = true + } + + now := time.Now() + + return r.db.QueryRow( + query, + price.ProductID, + price.CurrencyCode, + price.Price, + comparePrice, + now, + now, + ).Scan(&price.ID) } -// GetByID retrieves a product by ID +// GetByID gets a product by ID func (r *ProductRepository) GetByID(id uint) (*entity.Product, error) { query := ` SELECT id, product_number, name, description, price, stock, weight, category_id, seller_id, images, has_variants, created_at, updated_at @@ -92,12 +136,10 @@ func (r *ProductRepository) GetByID(id uint) (*entity.Product, error) { &product.CreatedAt, &product.UpdatedAt, ) - - if err == sql.ErrNoRows { - return nil, errors.New("product not found") - } - if err != nil { + if err == sql.ErrNoRows { + return nil, errors.New("product not found") + } return nil, err } @@ -111,18 +153,71 @@ func (r *ProductRepository) GetByID(id uint) (*entity.Product, error) { return nil, err } + // Load currency-specific prices + prices, err := r.getProductPrices(product.ID) + if err != nil { + return nil, err + } + product.Prices = prices + return product, nil } -// GetByIDWithVariants retrieves a product by ID including its variants +// getProductPrices retrieves all prices for a product in different currencies +func (r *ProductRepository) getProductPrices(productID uint) ([]entity.ProductPrice, error) { + query := ` + SELECT id, product_id, currency_code, price, compare_price, created_at, updated_at + FROM product_prices + WHERE product_id = $1 + ` + + rows, err := r.db.Query(query, productID) + if err != nil { + return nil, err + } + defer rows.Close() + + var prices []entity.ProductPrice + for rows.Next() { + var price entity.ProductPrice + var comparePrice sql.NullInt64 + + err := rows.Scan( + &price.ID, + &price.ProductID, + &price.CurrencyCode, + &price.Price, + &comparePrice, + &price.CreatedAt, + &price.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if comparePrice.Valid { + price.ComparePrice = comparePrice.Int64 + } + + prices = append(prices, price) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return prices, nil +} + +// GetByIDWithVariants gets a product by ID with variants func (r *ProductRepository) GetByIDWithVariants(id uint) (*entity.Product, error) { - // First get the product + // Get the base product product, err := r.GetByID(id) if err != nil { return nil, err } - // If product has variants, fetch them + // If product has variants, get them if product.HasVariants { query := ` SELECT id, product_id, sku, price, compare_price, stock, attributes, images, is_default, created_at, updated_at @@ -141,12 +236,14 @@ func (r *ProductRepository) GetByIDWithVariants(id uint) (*entity.Product, error for rows.Next() { var attributesJSON, imagesJSON []byte variant := &entity.ProductVariant{} + var comparePrice sql.NullInt64 + err := rows.Scan( &variant.ID, &variant.ProductID, &variant.SKU, &variant.Price, - &variant.ComparePrice, + &comparePrice, &variant.Stock, &attributesJSON, &imagesJSON, @@ -158,38 +255,27 @@ func (r *ProductRepository) GetByIDWithVariants(id uint) (*entity.Product, error return nil, err } - // Initialize empty attributes array - variant.Attributes = []entity.VariantAttribute{} + // Set compare price if valid + if comparePrice.Valid { + variant.ComparePrice = comparePrice.Int64 + } // Unmarshal attributes JSON - // Handle both array format and object format for backward compatibility - var rawAttributes interface{} - if err := json.Unmarshal(attributesJSON, &rawAttributes); err != nil { - return nil, fmt.Errorf("failed to unmarshal attributes: %w", err) + var attributes []map[string]interface{} + if err := json.Unmarshal(attributesJSON, &attributes); err != nil { + return nil, err } - // Check if the attributes are in array format - if attrsArray, ok := rawAttributes.([]interface{}); ok { - // Handle array format - for _, attr := range attrsArray { - if attrMap, ok := attr.(map[string]interface{}); ok { - name, _ := attrMap["name"].(string) - value, _ := attrMap["value"].(string) - variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ - Name: name, - Value: value, - }) - } - } - } else if attrsMap, ok := rawAttributes.(map[string]interface{}); ok { - // Handle object format (key-value pairs) - for name, value := range attrsMap { - if strValue, ok := value.(string); ok { - variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ - Name: name, - Value: strValue, - }) - } + // Convert attributes to VariantAttribute + variant.Attributes = make([]entity.VariantAttribute, 0, len(attributes)) + for _, attr := range attributes { + name, ok1 := attr["name"].(string) + value, ok2 := attr["value"].(string) + if ok1 && ok2 { + variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ + Name: name, + Value: value, + }) } } @@ -232,8 +318,28 @@ func (r *ProductRepository) Update(product *entity.Product) error { time.Now(), product.ID, ) + if err != nil { + return err + } - return err + // Update currency-specific prices if they exist + if len(product.Prices) > 0 { + // Use an upsert query to update or insert prices + query := ` + INSERT INTO product_prices (product_id, currency_code, price) + VALUES ($1, $2, $3) + ON CONFLICT (product_id, currency_code) + DO UPDATE SET price = EXCLUDED.price + ` + for _, price := range product.Prices { + _, err := r.db.Exec(query, product.ID, price.CurrencyCode, price.Price) + if err != nil { + return err + } + } + } + + return nil } // Delete deletes a product @@ -264,7 +370,7 @@ func (r *ProductRepository) Delete(id uint) error { return tx.Commit() } -// List retrieves all products with pagination +// List lists products with pagination func (r *ProductRepository) List(offset, limit int) ([]*entity.Product, error) { query := ` SELECT id, product_number, name, description, price, stock, weight, category_id, seller_id, images, has_variants, created_at, updated_at @@ -314,9 +420,20 @@ func (r *ProductRepository) List(offset, limit int) ([]*entity.Product, error) { return nil, err } + // Load currency-specific prices + prices, err := r.getProductPrices(product.ID) + if err != nil { + return nil, err + } + product.Prices = prices + products = append(products, product) } + if err = rows.Err(); err != nil { + return nil, err + } + return products, nil } @@ -402,13 +519,20 @@ func (r *ProductRepository) Search(query string, categoryID uint, minPriceCents, return nil, err } + // Load currency-specific prices + prices, err := r.getProductPrices(product.ID) + if err != nil { + return nil, err + } + product.Prices = prices + products = append(products, product) } return products, nil } -// GetBySeller retrieves products by seller ID +// GetBySeller gets products by seller ID with pagination func (r *ProductRepository) GetBySeller(sellerID uint, offset, limit int) ([]*entity.Product, error) { query := ` SELECT id, product_number, name, description, price, stock, weight, category_id, seller_id, images, has_variants, created_at, updated_at @@ -459,6 +583,13 @@ func (r *ProductRepository) GetBySeller(sellerID uint, offset, limit int) ([]*en return nil, err } + // Load currency-specific prices + prices, err := r.getProductPrices(product.ID) + if err != nil { + return nil, err + } + product.Prices = prices + products = append(products, product) } diff --git a/internal/infrastructure/repository/postgres/product_variant_repository.go b/internal/infrastructure/repository/postgres/product_variant_repository.go index 903b275..2f2f096 100644 --- a/internal/infrastructure/repository/postgres/product_variant_repository.go +++ b/internal/infrastructure/repository/postgres/product_variant_repository.go @@ -4,48 +4,64 @@ import ( "database/sql" "encoding/json" "errors" - "fmt" "time" "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/domain/repository" ) -// ProductVariantRepository implements the product variant repository interface using PostgreSQL +// ProductVariantRepository is the PostgreSQL implementation of the ProductVariantRepository interface type ProductVariantRepository struct { db *sql.DB } // NewProductVariantRepository creates a new ProductVariantRepository -func NewProductVariantRepository(db *sql.DB) *ProductVariantRepository { - return &ProductVariantRepository{db: db} +func NewProductVariantRepository(db *sql.DB) repository.ProductVariantRepository { + return &ProductVariantRepository{ + db: db, + } } // Create creates a new product variant func (r *ProductVariantRepository) Create(variant *entity.ProductVariant) error { query := ` - INSERT INTO product_variants ( - product_id, sku, price, compare_price, stock, attributes, images, is_default, created_at, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO product_variants (product_id, sku, price, compare_price, stock, attributes, images, is_default, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id ` - attributesJSON, err := json.Marshal(variant.Attributes) + // Convert attributes to JSON + attributes := make([]map[string]string, 0, len(variant.Attributes)) + for _, attr := range variant.Attributes { + attributes = append(attributes, map[string]string{ + "name": attr.Name, + "value": attr.Value, + }) + } + attributesJSON, err := json.Marshal(attributes) if err != nil { return err } + // Convert images to JSON imagesJSON, err := json.Marshal(variant.Images) if err != nil { return err } + // Handle compare price which may be null + var comparePrice sql.NullInt64 + if variant.ComparePrice > 0 { + comparePrice.Int64 = variant.ComparePrice + comparePrice.Valid = true + } + err = r.db.QueryRow( query, variant.ProductID, variant.SKU, variant.Price, - variant.ComparePrice, + comparePrice, variant.Stock, attributesJSON, imagesJSON, @@ -54,10 +70,67 @@ func (r *ProductVariantRepository) Create(variant *entity.ProductVariant) error variant.UpdatedAt, ).Scan(&variant.ID) - return err + if err != nil { + return err + } + + // If this is the default variant, update product price + if variant.IsDefault { + _, err = r.db.Exec( + "UPDATE products SET price = $1 WHERE id = $2", + variant.Price, + variant.ProductID, + ) + if err != nil { + return err + } + } + + // If the variant has currency-specific prices, save them + if len(variant.Prices) > 0 { + for i := range variant.Prices { + variant.Prices[i].VariantID = variant.ID + if err = r.createVariantPrice(&variant.Prices[i]); err != nil { + return err + } + } + } + + return nil } -// GetByID retrieves a product variant by ID +// createVariantPrice creates a variant price entry for a specific currency +func (r *ProductVariantRepository) createVariantPrice(price *entity.ProductVariantPrice) error { + query := ` + INSERT INTO product_variant_prices (variant_id, currency_code, price, compare_price, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (variant_id, currency_code) DO UPDATE SET + price = EXCLUDED.price, + compare_price = EXCLUDED.compare_price, + updated_at = EXCLUDED.updated_at + RETURNING id + ` + + var comparePrice sql.NullInt64 + if price.ComparePrice > 0 { + comparePrice.Int64 = price.ComparePrice + comparePrice.Valid = true + } + + now := time.Now() + + return r.db.QueryRow( + query, + price.VariantID, + price.CurrencyCode, + price.Price, + comparePrice, + now, + now, + ).Scan(&price.ID) +} + +// GetByID gets a variant by ID func (r *ProductVariantRepository) GetByID(id uint) (*entity.ProductVariant, error) { query := ` SELECT id, product_id, sku, price, compare_price, stock, attributes, images, is_default, created_at, updated_at @@ -67,12 +140,14 @@ func (r *ProductVariantRepository) GetByID(id uint) (*entity.ProductVariant, err var attributesJSON, imagesJSON []byte variant := &entity.ProductVariant{} + var comparePrice sql.NullInt64 + err := r.db.QueryRow(query, id).Scan( &variant.ID, &variant.ProductID, &variant.SKU, &variant.Price, - &variant.ComparePrice, + &comparePrice, &variant.Stock, &attributesJSON, &imagesJSON, @@ -81,46 +156,34 @@ func (r *ProductVariantRepository) GetByID(id uint) (*entity.ProductVariant, err &variant.UpdatedAt, ) - if err == sql.ErrNoRows { - return nil, errors.New("product variant not found") - } - if err != nil { + if err == sql.ErrNoRows { + return nil, errors.New("variant not found") + } return nil, err } - // Initialize empty attributes array - variant.Attributes = []entity.VariantAttribute{} + // Set compare price if valid + if comparePrice.Valid { + variant.ComparePrice = comparePrice.Int64 + } // Unmarshal attributes JSON - // Handle both array format and object format for backward compatibility - var rawAttributes interface{} - if err := json.Unmarshal(attributesJSON, &rawAttributes); err != nil { - return nil, fmt.Errorf("failed to unmarshal attributes: %w", err) - } - - // Check if the attributes are in array format - if attrsArray, ok := rawAttributes.([]interface{}); ok { - // Handle array format - for _, attr := range attrsArray { - if attrMap, ok := attr.(map[string]interface{}); ok { - name, _ := attrMap["name"].(string) - value, _ := attrMap["value"].(string) - variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ - Name: name, - Value: value, - }) - } - } - } else if attrsMap, ok := rawAttributes.(map[string]interface{}); ok { - // Handle object format (key-value pairs) - for name, value := range attrsMap { - if strValue, ok := value.(string); ok { - variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ - Name: name, - Value: strValue, - }) - } + var attributes []map[string]interface{} + if err := json.Unmarshal(attributesJSON, &attributes); err != nil { + return nil, err + } + + // Convert attributes to VariantAttribute + variant.Attributes = make([]entity.VariantAttribute, 0, len(attributes)) + for _, attr := range attributes { + name, ok1 := attr["name"].(string) + value, ok2 := attr["value"].(string) + if ok1 && ok2 { + variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ + Name: name, + Value: value, + }) } } @@ -129,85 +192,225 @@ func (r *ProductVariantRepository) GetByID(id uint) (*entity.ProductVariant, err return nil, err } + // Load currency-specific prices + prices, err := r.getVariantPrices(variant.ID) + if err != nil { + return nil, err + } + variant.Prices = prices + return variant, nil } -// GetBySKU retrieves a product variant by SKU -func (r *ProductVariantRepository) GetBySKU(sku string) (*entity.ProductVariant, error) { +// getVariantPrices retrieves all prices for a variant in different currencies +func (r *ProductVariantRepository) getVariantPrices(variantID uint) ([]entity.ProductVariantPrice, error) { query := ` - SELECT id, product_id, sku, price, compare_price, stock, attributes, images, is_default, created_at, updated_at - FROM product_variants - WHERE sku = $1 + SELECT id, variant_id, currency_code, price, compare_price, created_at, updated_at + FROM product_variant_prices + WHERE variant_id = $1 ` - var attributesJSON, imagesJSON []byte - variant := &entity.ProductVariant{} - err := r.db.QueryRow(query, sku).Scan( - &variant.ID, - &variant.ProductID, - &variant.SKU, - &variant.Price, - &variant.ComparePrice, - &variant.Stock, - &attributesJSON, - &imagesJSON, - &variant.IsDefault, - &variant.CreatedAt, - &variant.UpdatedAt, - ) + rows, err := r.db.Query(query, variantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var prices []entity.ProductVariantPrice + for rows.Next() { + var price entity.ProductVariantPrice + var comparePrice sql.NullInt64 + + err := rows.Scan( + &price.ID, + &price.VariantID, + &price.CurrencyCode, + &price.Price, + &comparePrice, + &price.CreatedAt, + &price.UpdatedAt, + ) + if err != nil { + return nil, err + } - if err == sql.ErrNoRows { - return nil, errors.New("product variant not found") + if comparePrice.Valid { + price.ComparePrice = comparePrice.Int64 + } + + prices = append(prices, price) } - if err != nil { + if err = rows.Err(); err != nil { return nil, err } - // Initialize empty attributes array - variant.Attributes = []entity.VariantAttribute{} + return prices, nil +} - // Unmarshal attributes JSON - // Handle both array format and object format for backward compatibility - var rawAttributes interface{} - if err := json.Unmarshal(attributesJSON, &rawAttributes); err != nil { - return nil, fmt.Errorf("failed to unmarshal attributes: %w", err) - } - - // Check if the attributes are in array format - if attrsArray, ok := rawAttributes.([]interface{}); ok { - // Handle array format - for _, attr := range attrsArray { - if attrMap, ok := attr.(map[string]interface{}); ok { - name, _ := attrMap["name"].(string) - value, _ := attrMap["value"].(string) - variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ - Name: name, - Value: value, - }) - } +// Update updates a product variant +func (r *ProductVariantRepository) Update(variant *entity.ProductVariant) error { + query := ` + UPDATE product_variants + SET sku = $1, price = $2, compare_price = $3, stock = $4, + attributes = $5, images = $6, is_default = $7, updated_at = $8 + WHERE id = $9 + ` + + // Convert attributes to JSON + attributes := make([]map[string]string, 0, len(variant.Attributes)) + for _, attr := range variant.Attributes { + attributes = append(attributes, map[string]string{ + "name": attr.Name, + "value": attr.Value, + }) + } + attributesJSON, err := json.Marshal(attributes) + if err != nil { + return err + } + + // Convert images to JSON + imagesJSON, err := json.Marshal(variant.Images) + if err != nil { + return err + } + + // Handle compare price which may be null + var comparePrice sql.NullInt64 + if variant.ComparePrice > 0 { + comparePrice.Int64 = variant.ComparePrice + comparePrice.Valid = true + } + + _, err = r.db.Exec( + query, + variant.SKU, + variant.Price, + comparePrice, + variant.Stock, + attributesJSON, + imagesJSON, + variant.IsDefault, + time.Now(), + variant.ID, + ) + + if err != nil { + return err + } + + // If this is the default variant, update product price + if variant.IsDefault { + _, err = r.db.Exec( + "UPDATE products SET price = $1 WHERE id = $2", + variant.Price, + variant.ProductID, + ) + if err != nil { + return err } - } else if attrsMap, ok := rawAttributes.(map[string]interface{}); ok { - // Handle object format (key-value pairs) - for name, value := range attrsMap { - if strValue, ok := value.(string); ok { - variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ - Name: name, - Value: strValue, - }) + } + + // Update currency-specific prices + if len(variant.Prices) > 0 { + // First, delete existing prices (to handle removes) + if _, err := r.db.Exec("DELETE FROM product_variant_prices WHERE variant_id = $1", variant.ID); err != nil { + return err + } + + // Then add all current prices + for i := range variant.Prices { + variant.Prices[i].VariantID = variant.ID + if err := r.createVariantPrice(&variant.Prices[i]); err != nil { + return err } } } - // Unmarshal images JSON - if err := json.Unmarshal(imagesJSON, &variant.Images); err != nil { - return nil, err + return nil +} + +// Delete deletes a product variant +func (r *ProductVariantRepository) Delete(id uint) error { + // Check if this is the only variant or if it's the default variant + var isDefault bool + var productID uint + var variantCount int + + err := r.db.QueryRow( + "SELECT is_default, product_id FROM product_variants WHERE id = $1", + id, + ).Scan(&isDefault, &productID) + if err != nil { + return err } - return variant, nil + err = r.db.QueryRow( + "SELECT COUNT(*) FROM product_variants WHERE product_id = $1", + productID, + ).Scan(&variantCount) + if err != nil { + return err + } + + // Start a transaction + tx, err := r.db.Begin() + if err != nil { + return err + } + defer func() { + if err != nil { + tx.Rollback() + } + }() + + // Delete the variant + _, err = tx.Exec("DELETE FROM product_variants WHERE id = $1", id) + if err != nil { + return err + } + + // If this was the only variant, update product to not have variants + if variantCount == 1 { + _, err = tx.Exec( + "UPDATE products SET has_variants = false WHERE id = $1", + productID, + ) + if err != nil { + return err + } + } else if isDefault { + // If this was the default variant, set another variant as default + _, err = tx.Exec(` + UPDATE product_variants + SET is_default = true + WHERE product_id = $1 + AND id != $2 + LIMIT 1 + `, productID, id) + if err != nil { + return err + } + + // Update product price to match the new default variant + _, err = tx.Exec(` + UPDATE products p + SET price = v.price + FROM product_variants v + WHERE p.id = v.product_id + AND v.product_id = $1 + AND v.is_default = true + `, productID) + if err != nil { + return err + } + } + + return tx.Commit() } -// GetByProduct retrieves all variants for a product +// GetByProductID gets all variants for a product func (r *ProductVariantRepository) GetByProduct(productID uint) ([]*entity.ProductVariant, error) { query := ` SELECT id, product_id, sku, price, compare_price, stock, attributes, images, is_default, created_at, updated_at @@ -226,12 +429,14 @@ func (r *ProductVariantRepository) GetByProduct(productID uint) ([]*entity.Produ for rows.Next() { var attributesJSON, imagesJSON []byte variant := &entity.ProductVariant{} + var comparePrice sql.NullInt64 + err := rows.Scan( &variant.ID, &variant.ProductID, &variant.SKU, &variant.Price, - &variant.ComparePrice, + &comparePrice, &variant.Stock, &attributesJSON, &imagesJSON, @@ -243,38 +448,27 @@ func (r *ProductVariantRepository) GetByProduct(productID uint) ([]*entity.Produ return nil, err } - // Initialize empty attributes array - variant.Attributes = []entity.VariantAttribute{} + // Set compare price if valid + if comparePrice.Valid { + variant.ComparePrice = comparePrice.Int64 + } // Unmarshal attributes JSON - // Handle both array format and object format for backward compatibility - var rawAttributes interface{} - if err := json.Unmarshal(attributesJSON, &rawAttributes); err != nil { - return nil, fmt.Errorf("failed to unmarshal attributes: %w", err) + var attributes []map[string]interface{} + if err := json.Unmarshal(attributesJSON, &attributes); err != nil { + return nil, err } - // Check if the attributes are in array format - if attrsArray, ok := rawAttributes.([]interface{}); ok { - // Handle array format - for _, attr := range attrsArray { - if attrMap, ok := attr.(map[string]interface{}); ok { - name, _ := attrMap["name"].(string) - value, _ := attrMap["value"].(string) - variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ - Name: name, - Value: value, - }) - } - } - } else if attrsMap, ok := rawAttributes.(map[string]interface{}); ok { - // Handle object format (key-value pairs) - for name, value := range attrsMap { - if strValue, ok := value.(string); ok { - variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ - Name: name, - Value: strValue, - }) - } + // Convert attributes to VariantAttribute + variant.Attributes = make([]entity.VariantAttribute, 0, len(attributes)) + for _, attr := range attributes { + name, ok1 := attr["name"].(string) + value, ok2 := attr["value"].(string) + if ok1 && ok2 { + variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ + Name: name, + Value: value, + }) } } @@ -283,54 +477,95 @@ func (r *ProductVariantRepository) GetByProduct(productID uint) ([]*entity.Produ return nil, err } + // Load currency-specific prices + prices, err := r.getVariantPrices(variant.ID) + if err != nil { + return nil, err + } + variant.Prices = prices + variants = append(variants, variant) } + if err = rows.Err(); err != nil { + return nil, err + } + return variants, nil } -// Update updates a product variant -func (r *ProductVariantRepository) Update(variant *entity.ProductVariant) error { +// GetBySKU gets a variant by SKU +func (r *ProductVariantRepository) GetBySKU(sku string) (*entity.ProductVariant, error) { query := ` - UPDATE product_variants - SET sku = $1, price = $2, compare_price = $3, stock = $4, attributes = $5, images = $6, is_default = $7, updated_at = $8 - WHERE id = $9 + SELECT id, product_id, sku, price, compare_price, stock, attributes, images, is_default, created_at, updated_at + FROM product_variants + WHERE sku = $1 ` - attributesJSON, err := json.Marshal(variant.Attributes) + var attributesJSON, imagesJSON []byte + variant := &entity.ProductVariant{} + var comparePrice sql.NullInt64 + + err := r.db.QueryRow(query, sku).Scan( + &variant.ID, + &variant.ProductID, + &variant.SKU, + &variant.Price, + &comparePrice, + &variant.Stock, + &attributesJSON, + &imagesJSON, + &variant.IsDefault, + &variant.CreatedAt, + &variant.UpdatedAt, + ) + if err != nil { - return err + if err == sql.ErrNoRows { + return nil, errors.New("variant not found") + } + return nil, err } - imagesJSON, err := json.Marshal(variant.Images) - if err != nil { - return err + // Set compare price if valid + if comparePrice.Valid { + variant.ComparePrice = comparePrice.Int64 } - _, err = r.db.Exec( - query, - variant.SKU, - variant.Price, - variant.ComparePrice, - variant.Stock, - attributesJSON, - imagesJSON, - variant.IsDefault, - time.Now(), - variant.ID, - ) + // Unmarshal attributes JSON + var attributes []map[string]interface{} + if err := json.Unmarshal(attributesJSON, &attributes); err != nil { + return nil, err + } - return err -} + // Convert attributes to VariantAttribute + variant.Attributes = make([]entity.VariantAttribute, 0, len(attributes)) + for _, attr := range attributes { + name, ok1 := attr["name"].(string) + value, ok2 := attr["value"].(string) + if ok1 && ok2 { + variant.Attributes = append(variant.Attributes, entity.VariantAttribute{ + Name: name, + Value: value, + }) + } + } -// Delete deletes a product variant -func (r *ProductVariantRepository) Delete(id uint) error { - query := `DELETE FROM product_variants WHERE id = $1` - _, err := r.db.Exec(query, id) - return err + // Unmarshal images JSON + if err := json.Unmarshal(imagesJSON, &variant.Images); err != nil { + return nil, err + } + + // Load currency-specific prices + prices, err := r.getVariantPrices(variant.ID) + if err != nil { + return nil, err + } + variant.Prices = prices + + return variant, nil } -// BatchCreate creates multiple product variants in a single transaction func (r *ProductVariantRepository) BatchCreate(variants []*entity.ProductVariant) error { tx, err := r.db.Begin() if err != nil { @@ -339,53 +574,15 @@ func (r *ProductVariantRepository) BatchCreate(variants []*entity.ProductVariant defer func() { if err != nil { tx.Rollback() - return } - err = tx.Commit() }() - query := ` - INSERT INTO product_variants ( - product_id, sku, price, compare_price, stock, attributes, images, is_default, created_at, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING id - ` - - stmt, err := tx.Prepare(query) - if err != nil { - return err - } - defer stmt.Close() - for _, variant := range variants { - attributesJSON, err := json.Marshal(variant.Attributes) - if err != nil { - return err - } - - imagesJSON, err := json.Marshal(variant.Images) - if err != nil { - return err - } - - err = stmt.QueryRow( - variant.ProductID, - variant.SKU, - variant.Price, - variant.ComparePrice, - variant.Stock, - attributesJSON, - imagesJSON, - variant.IsDefault, - variant.CreatedAt, - variant.UpdatedAt, - ).Scan(&variant.ID) - + err = r.Create(variant) if err != nil { return err } } - return nil + return tx.Commit() } diff --git a/internal/interfaces/api/handler/currency_handler.go b/internal/interfaces/api/handler/currency_handler.go new file mode 100644 index 0000000..3e45db2 --- /dev/null +++ b/internal/interfaces/api/handler/currency_handler.go @@ -0,0 +1,269 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/zenfulcode/commercify/internal/application/usecase" + "github.com/zenfulcode/commercify/internal/domain/money" + "github.com/zenfulcode/commercify/internal/infrastructure/logger" +) + +// CurrencyHandler handles currency-related HTTP requests +type CurrencyHandler struct { + currencyUseCase *usecase.CurrencyUseCase + logger logger.Logger +} + +// NewCurrencyHandler creates a new CurrencyHandler +func NewCurrencyHandler(currencyUseCase *usecase.CurrencyUseCase, logger logger.Logger) *CurrencyHandler { + return &CurrencyHandler{ + currencyUseCase: currencyUseCase, + logger: logger, + } +} + +// ListCurrencies handles listing all currencies +func (h *CurrencyHandler) ListCurrencies(w http.ResponseWriter, r *http.Request) { + // Get currencies + currencies, err := h.currencyUseCase.ListCurrencies() + if err != nil { + h.logger.Error("Failed to list currencies: %v", err) + http.Error(w, "Failed to list currencies", http.StatusInternalServerError) + return + } + + // Return currencies + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(currencies) +} + +// ListEnabledCurrencies handles listing all enabled currencies +func (h *CurrencyHandler) ListEnabledCurrencies(w http.ResponseWriter, r *http.Request) { + // Get enabled currencies + currencies, err := h.currencyUseCase.ListEnabledCurrencies() + if err != nil { + h.logger.Error("Failed to list enabled currencies: %v", err) + http.Error(w, "Failed to list enabled currencies", http.StatusInternalServerError) + return + } + + // Return currencies + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(currencies) +} + +// GetCurrency handles retrieving a currency by code +func (h *CurrencyHandler) GetCurrency(w http.ResponseWriter, r *http.Request) { + // Get currency code from query parameter + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Currency code is required", http.StatusBadRequest) + return + } + + // Get currency + currency, err := h.currencyUseCase.GetCurrency(code) + if err != nil { + h.logger.Error("Failed to get currency: %v", err) + http.Error(w, "Currency not found", http.StatusNotFound) + return + } + + // Return currency + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(currency) +} + +// GetDefaultCurrency handles retrieving the default currency +func (h *CurrencyHandler) GetDefaultCurrency(w http.ResponseWriter, r *http.Request) { + // Get default currency + currency, err := h.currencyUseCase.GetDefaultCurrency() + if err != nil { + h.logger.Error("Failed to get default currency: %v", err) + http.Error(w, "Default currency not found", http.StatusNotFound) + return + } + + // Return currency + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(currency) +} + +// CreateCurrency handles creating a new currency (admin only) +func (h *CurrencyHandler) CreateCurrency(w http.ResponseWriter, r *http.Request) { + // Parse request body + var input usecase.CurrencyInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Create currency + currency, err := h.currencyUseCase.CreateCurrency(input) + if err != nil { + h.logger.Error("Failed to create currency: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Return created currency + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(currency) +} + +// UpdateCurrency handles updating a currency (admin only) +func (h *CurrencyHandler) UpdateCurrency(w http.ResponseWriter, r *http.Request) { + // Get currency code from query parameter + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Currency code is required", http.StatusBadRequest) + return + } + + // Parse request body + var input usecase.CurrencyInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Update currency + currency, err := h.currencyUseCase.UpdateCurrency(code, input) + if err != nil { + h.logger.Error("Failed to update currency: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Return updated currency + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(currency) +} + +// DeleteCurrency handles deleting a currency (admin only) +func (h *CurrencyHandler) DeleteCurrency(w http.ResponseWriter, r *http.Request) { + // Get currency code from query parameter + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Currency code is required", http.StatusBadRequest) + return + } + + // Ensure we're not trying to delete the default currency + currency, err := h.currencyUseCase.GetCurrency(code) + if err != nil { + h.logger.Error("Failed to get currency: %v", err) + http.Error(w, "Currency not found", http.StatusNotFound) + return + } + + if currency.IsDefault { + http.Error(w, "Cannot delete the default currency", http.StatusBadRequest) + return + } + + // Delete currency + err = h.currencyUseCase.DeleteCurrency(code) + if err != nil { + h.logger.Error("Failed to delete currency: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Return success + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "message": "Currency deleted successfully", + }) +} + +// SetDefaultCurrency handles setting a currency as the default (admin only) +func (h *CurrencyHandler) SetDefaultCurrency(w http.ResponseWriter, r *http.Request) { + // Get currency code from query parameter + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Currency code is required", http.StatusBadRequest) + return + } + + // Set as default + err := h.currencyUseCase.SetDefaultCurrency(code) + if err != nil { + h.logger.Error("Failed to set default currency: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Get updated currency + currency, err := h.currencyUseCase.GetCurrency(code) + if err != nil { + h.logger.Error("Failed to get updated currency: %v", err) + http.Error(w, "Currency not found", http.StatusNotFound) + return + } + + // Return updated currency + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(currency) +} + +// ConvertAmount handles converting an amount from one currency to another +func (h *CurrencyHandler) ConvertAmount(w http.ResponseWriter, r *http.Request) { + // Parse request body + var requestBody struct { + Amount float64 `json:"amount"` + FromCurrency string `json:"from_currency"` + ToCurrency string `json:"to_currency"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate required fields + if requestBody.Amount <= 0 { + http.Error(w, "Amount must be greater than zero", http.StatusBadRequest) + return + } + + if strings.TrimSpace(requestBody.FromCurrency) == "" { + http.Error(w, "From currency is required", http.StatusBadRequest) + return + } + + if strings.TrimSpace(requestBody.ToCurrency) == "" { + http.Error(w, "To currency is required", http.StatusBadRequest) + return + } + + // Convert amount + fromCents := money.ToCents(requestBody.Amount) + toCents, err := h.currencyUseCase.ConvertPrice(fromCents, requestBody.FromCurrency, requestBody.ToCurrency) + if err != nil { + h.logger.Error("Failed to convert amount: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Return converted amount + response := map[string]interface{}{ + "from": map[string]interface{}{ + "currency": requestBody.FromCurrency, + "amount": requestBody.Amount, + "cents": fromCents, + }, + "to": map[string]interface{}{ + "currency": requestBody.ToCurrency, + "amount": money.FromCents(toCents), + "cents": toCents, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/internal/interfaces/api/handler/product_handler.go b/internal/interfaces/api/handler/product_handler.go index 99e75f1..06b0670 100644 --- a/internal/interfaces/api/handler/product_handler.go +++ b/internal/interfaces/api/handler/product_handler.go @@ -9,6 +9,7 @@ import ( "github.com/gorilla/mux" "github.com/zenfulcode/commercify/internal/application/usecase" "github.com/zenfulcode/commercify/internal/domain/entity" + errors "github.com/zenfulcode/commercify/internal/domain/error" "github.com/zenfulcode/commercify/internal/domain/money" "github.com/zenfulcode/commercify/internal/infrastructure/logger" ) @@ -167,10 +168,25 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { } // Get product (use case returns entity with cents) - product, err := h.productUseCase.GetProductByID(uint(id)) + currencyCode := vars["currency"] + + var product *entity.Product + + if currencyCode != "" { + // Get product with specific currency prices + product, err = h.productUseCase.GetProductByCurrency(uint(id), currencyCode) + } else { + // Get product with default currency prices + product, err = h.productUseCase.GetProductByID(uint(id)) + } + if err != nil { h.logger.Error("Failed to get product: %v", err) - http.Error(w, "Product not found", http.StatusNotFound) + if err.Error() == errors.ProductNotFoundError { + http.Error(w, err.Error(), http.StatusNotFound) + } else { + http.Error(w, err.Error(), http.StatusBadRequest) + } return } diff --git a/internal/interfaces/api/server.go b/internal/interfaces/api/server.go index 94e5d3a..dfb01f6 100644 --- a/internal/interfaces/api/server.go +++ b/internal/interfaces/api/server.go @@ -78,6 +78,7 @@ func (s *Server) setupRoutes() { webhookHandler := s.container.Handlers().WebhookHandler() discountHandler := s.container.Handlers().DiscountHandler() shippingHandler := s.container.Handlers().ShippingHandler() + currencyHandler := s.container.Handlers().CurrencyHandler() // Extract middleware from container authMiddleware := s.container.Middlewares().AuthMiddleware() @@ -95,6 +96,11 @@ func (s *Server) setupRoutes() { api.HandleFunc("/payment/providers", paymentHandler.GetAvailablePaymentProviders).Methods(http.MethodGet) api.HandleFunc("/discounts/validate", discountHandler.ValidateDiscountCode).Methods(http.MethodPost) + // Public currency routes + api.HandleFunc("/currencies", currencyHandler.ListEnabledCurrencies).Methods(http.MethodGet) + api.HandleFunc("/currencies/default", currencyHandler.GetDefaultCurrency).Methods(http.MethodGet) + api.HandleFunc("/currencies/convert", currencyHandler.ConvertAmount).Methods(http.MethodPost) + // Public shipping routes api.HandleFunc("/shipping/methods", shippingHandler.ListShippingMethods).Methods(http.MethodGet) api.HandleFunc("/shipping/methods/{id:[0-9]+}", shippingHandler.GetShippingMethodByID).Methods(http.MethodGet) @@ -172,6 +178,13 @@ func (s *Server) setupRoutes() { admin.HandleFunc("/orders", orderHandler.ListAllOrders).Methods(http.MethodGet) admin.HandleFunc("/orders/{id:[0-9]+}/status", orderHandler.UpdateOrderStatus).Methods(http.MethodPut) + // Admin currency routes + admin.HandleFunc("/currencies/all", currencyHandler.ListCurrencies).Methods(http.MethodGet) + admin.HandleFunc("/currencies", currencyHandler.CreateCurrency).Methods(http.MethodPost) + admin.HandleFunc("/currencies", currencyHandler.UpdateCurrency).Methods(http.MethodPut) + admin.HandleFunc("/currencies", currencyHandler.DeleteCurrency).Methods(http.MethodDelete) + admin.HandleFunc("/currencies/default", currencyHandler.SetDefaultCurrency).Methods(http.MethodPut) + // Shipping management routes (admin only) admin.HandleFunc("/shipping/methods", shippingHandler.CreateShippingMethod).Methods(http.MethodPost) admin.HandleFunc("/shipping/methods/{id:[0-9]+}", shippingHandler.UpdateShippingMethod).Methods(http.MethodPut) diff --git a/migrations/000014_add_currency_support.down.sql b/migrations/000014_add_currency_support.down.sql new file mode 100644 index 0000000..d97b187 --- /dev/null +++ b/migrations/000014_add_currency_support.down.sql @@ -0,0 +1,17 @@ +-- Remove currency support from the database + +-- Drop indexes +DROP INDEX IF EXISTS idx_product_variant_prices_currency_code; +DROP INDEX IF EXISTS idx_product_variant_prices_variant_id; +DROP INDEX IF EXISTS idx_product_prices_currency_code; +DROP INDEX IF EXISTS idx_product_prices_product_id; + +-- Drop tables +DROP TABLE IF EXISTS product_variant_prices; +DROP TABLE IF EXISTS product_prices; + +-- Remove default constraint on payment_transactions.currency +ALTER TABLE payment_transactions ALTER COLUMN currency DROP DEFAULT; + +-- Drop currencies table +DROP TABLE IF EXISTS currencies; \ No newline at end of file diff --git a/migrations/000014_add_currency_support.up.sql b/migrations/000014_add_currency_support.up.sql new file mode 100644 index 0000000..3ac99de --- /dev/null +++ b/migrations/000014_add_currency_support.up.sql @@ -0,0 +1,57 @@ +-- Add currency support to the database + +-- Create currencies table +CREATE TABLE IF NOT EXISTS currencies ( + code VARCHAR(3) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + symbol VARCHAR(10) NOT NULL, + exchange_rate DECIMAL(16, 6) NOT NULL DEFAULT 1.0, + is_default BOOLEAN NOT NULL DEFAULT false, + is_enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Create product_prices table to store prices in different currencies +CREATE TABLE IF NOT EXISTS product_prices ( + id SERIAL PRIMARY KEY, + product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + currency_code VARCHAR(3) NOT NULL REFERENCES currencies(code) ON DELETE CASCADE, + price BIGINT NOT NULL, -- stored in cents/smallest currency unit + compare_price BIGINT DEFAULT NULL, -- stored in cents/smallest currency unit + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(product_id, currency_code) +); + +-- Create product_variant_prices table to store variant prices in different currencies +CREATE TABLE IF NOT EXISTS product_variant_prices ( + id SERIAL PRIMARY KEY, + variant_id INT NOT NULL REFERENCES product_variants(id) ON DELETE CASCADE, + currency_code VARCHAR(3) NOT NULL REFERENCES currencies(code) ON DELETE CASCADE, + price BIGINT NOT NULL, -- stored in cents/smallest currency unit + compare_price BIGINT DEFAULT NULL, -- stored in cents/smallest currency unit + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(variant_id, currency_code) +); + +-- Add default currency column to payment_transactions +ALTER TABLE payment_transactions ALTER COLUMN currency SET DEFAULT 'USD'; + +-- Create indexes for better query performance +CREATE INDEX idx_product_prices_product_id ON product_prices(product_id); +CREATE INDEX idx_product_prices_currency_code ON product_prices(currency_code); +CREATE INDEX idx_product_variant_prices_variant_id ON product_variant_prices(variant_id); +CREATE INDEX idx_product_variant_prices_currency_code ON product_variant_prices(currency_code); + +-- Insert default currencies +INSERT INTO currencies (code, name, symbol, exchange_rate, is_default, is_enabled) +VALUES +('USD', 'US Dollar', '$', 1.0, true, true), +('EUR', 'Euro', '€', 0.85, false, true), +('DKK', 'Danish Krone', 'kr', 0.15, false, true), +('GBP', 'British Pound', '£', 0.75, false, true), +('JPY', 'Japanese Yen', '¥', 110.0, false, true), +('CAD', 'Canadian Dollar', 'CA$', 1.25, false, true) +ON CONFLICT (code) DO NOTHING; \ No newline at end of file diff --git a/testutil/mock/currency_repository.go b/testutil/mock/currency_repository.go new file mode 100644 index 0000000..571c0e3 --- /dev/null +++ b/testutil/mock/currency_repository.go @@ -0,0 +1,148 @@ +package mock + +import ( + "fmt" + + "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/domain/money" +) + +type MockCurrencyRepository struct { + currencies map[string]*entity.Currency + defaultCurrency *entity.Currency +} + +func NewMockCurrencyRepository() *MockCurrencyRepository { + return &MockCurrencyRepository{ + currencies: make(map[string]*entity.Currency), + defaultCurrency: &entity.Currency{ + Code: "USD", + Name: "US Dollar", + Symbol: "$", + ExchangeRate: 1.0, + IsEnabled: true, + IsDefault: true, + }, + } +} + +func (r *MockCurrencyRepository) Create(currency *entity.Currency) error { + if _, exists := r.currencies[currency.Code]; exists { + return fmt.Errorf("currency with code %s already exists", currency.Code) + } + r.currencies[currency.Code] = currency + return nil +} + +func (r *MockCurrencyRepository) Update(currency *entity.Currency) error { + if _, exists := r.currencies[currency.Code]; !exists { + return fmt.Errorf("currency with code %s does not exist", currency.Code) + } + r.currencies[currency.Code] = currency + return nil +} + +func (r *MockCurrencyRepository) Delete(code string) error { + if _, exists := r.currencies[code]; !exists { + return fmt.Errorf("currency with code %s does not exist", code) + } + delete(r.currencies, code) + return nil +} +func (r *MockCurrencyRepository) GetByCode(code string) (*entity.Currency, error) { + if currency, exists := r.currencies[code]; exists { + return currency, nil + } + return nil, fmt.Errorf("currency with code %s does not exist", code) +} +func (r *MockCurrencyRepository) GetDefault() (*entity.Currency, error) { + return r.defaultCurrency, nil +} +func (r *MockCurrencyRepository) List() ([]*entity.Currency, error) { + var currencies []*entity.Currency + for _, currency := range r.currencies { + currencies = append(currencies, currency) + } + return currencies, nil +} +func (r *MockCurrencyRepository) ListEnabled() ([]*entity.Currency, error) { + var currencies []*entity.Currency + for _, currency := range r.currencies { + if currency.IsEnabled { + currencies = append(currencies, currency) + } + } + return currencies, nil +} +func (r *MockCurrencyRepository) SetDefault(code string) error { + if _, exists := r.currencies[code]; !exists { + return fmt.Errorf("currency with code %s does not exist", code) + } + + for _, currency := range r.currencies { + currency.IsDefault = false + } + + r.currencies[code].IsDefault = true + r.defaultCurrency = r.currencies[code] + return nil +} + +// Product price operations +func (r *MockCurrencyRepository) GetProductPrices(productID uint) ([]entity.ProductPrice, error) { + if productID == 0 { + return nil, fmt.Errorf("product ID cannot be zero") + } + var prices []entity.ProductPrice + for _, currency := range r.currencies { + price := entity.ProductPrice{ + ProductID: productID, + CurrencyCode: currency.Code, + Price: money.ToCents(100.0), + } + prices = append(prices, price) + } + return prices, nil +} + +// SetProductPrices(productID uint, prices []entity.ProductPrice) error +func (r *MockCurrencyRepository) DeleteProductPrice(productID uint, currencyCode string) error { + if productID == 0 { + return fmt.Errorf("product ID cannot be zero") + } + if _, exists := r.currencies[currencyCode]; !exists { + return fmt.Errorf("currency with code %s does not exist", currencyCode) + } + return nil +} + +// SetProductPrice(price *entity.ProductPrice) error + +// Product variant price operations +func (r *MockCurrencyRepository) GetVariantPrices(variantID uint) ([]entity.ProductVariantPrice, error) { + if variantID == 0 { + return nil, fmt.Errorf("variant ID cannot be zero") + } + var prices []entity.ProductVariantPrice + for _, currency := range r.currencies { + price := entity.ProductVariantPrice{ + VariantID: variantID, + CurrencyCode: currency.Code, + Price: money.ToCents(100.0), + } + prices = append(prices, price) + } + return prices, nil +} + +// SetVariantPrices(variantID uint, prices []entity.ProductVariantPrice) error +// SetVariantPrice(prices *entity.ProductVariantPrice) error +func (r *MockCurrencyRepository) DeleteVariantPrice(variantID uint, currencyCode string) error { + if variantID == 0 { + return fmt.Errorf("variant ID cannot be zero") + } + if _, exists := r.currencies[currencyCode]; !exists { + return fmt.Errorf("currency with code %s does not exist", currencyCode) + } + return nil +}