diff --git a/FoxIDs.sln b/FoxIDs.sln index 970af340d..32ed5c578 100644 --- a/FoxIDs.sln +++ b/FoxIDs.sln @@ -270,6 +270,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{93A3AF EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure", "Azure", "{376A9D7A-D64B-4526-8EFC-D21A3B77612B}" ProjectSection(SolutionItems) = preProject + azuredeploy-small.json = azuredeploy-small.json azuredeploy.json = azuredeploy.json EndProjectSection EndProject diff --git a/Kubernetes/k8s-foxids-ingress-deployment.yaml b/Kubernetes/k8s-foxids-ingress-deployment.yaml index 77f0ddb7b..18d65b078 100644 --- a/Kubernetes/k8s-foxids-ingress-deployment.yaml +++ b/Kubernetes/k8s-foxids-ingress-deployment.yaml @@ -26,7 +26,7 @@ spec: name: foxids port: number: 8800 - - host: control.itfoxtec.com + - host: control.itfoxtec.com # change to your domain - control.my-domain.com http: paths: - path: / diff --git a/azuredeploy-small.json b/azuredeploy-small.json new file mode 100644 index 000000000..9be26d688 --- /dev/null +++ b/azuredeploy-small.json @@ -0,0 +1,625 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "appServicePlanSize": { + "defaultValue": "P0V3", + "allowedValues": [ + "F1", + "B1", + "B2", + "B3", + "S1", + "S2", + "S3", + "P1V2", + "P2V2", + "P3V2", + "P0V3", + "P1V3", + "P2V3", + "P3V3" + ], + "type": "string", + "metadata": { + "description": "The instance size of the App Service Plan." + } + }, + "appServicePlanSku": { + "defaultValue": "Standard", + "allowedValues": [ + "Free", + "Shared", + "Basic", + "Standard", + "Premium" + ], + "type": "string", + "metadata": { + "description": "The pricing tier of the App Service plan." + } + }, + "keyVaultSkuName": { + "type": "string", + "defaultValue": "Standard", + "allowedValues": [ + "Standard", + "Premium" + ], + "metadata": { + "description": "Specifies whether the key vault is a standard vault or a premium vault." + } + }, + "sendgridFromEmail": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional, Sendgrid from email address." + } + }, + "sendgridApiKey": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional, Sendgrid API key." + } + } + }, + "variables": { + "prefixName": "foxids", + "suffix": "[uniqueString(resourceGroup().id, resourceGroup().location)]", + "foxidsDefaultName": "[toLower(concat(variables('prefixName'), variables('suffix')))]", + "foxidsControlSiteName": "[toLower(concat(variables('prefixName'), 'control', variables('suffix')))]", + "foxidsSiteEndpoint": "[concat('https://', variables('foxidsDefaultName'), '.azurewebsites.net')]", + "foxidsControlSiteEndpoint": "[concat('https://', variables('foxidsControlSiteName'), '.azurewebsites.net')]" + }, + "resources": [ + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "apiVersion": "2022-08-15", + "name": "[variables('foxidsDefaultName')]", + "location": "[resourceGroup().location]", + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('foxidsDefaultName'), 'subnet-data')]" + ], + "tags": { + "defaultExperience": "DocumentDB" + }, + "kind": "GlobalDocumentDB", + "properties": { + "enableAutomaticFailover": false, + "enableMultipleWriteLocations": false, + "isVirtualNetworkFilterEnabled": true, + "virtualNetworkRules": [ + { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('foxidsDefaultName'), 'subnet-data')]", + "ignoreMissingVNetServiceEndpoint": false + } + ], + "databaseAccountOfferType": "Standard", + "consistencyPolicy": { + "defaultConsistencyLevel": "Session", + "maxIntervalInSeconds": 5, + "maxStalenessPrefix": 100 + }, + "locations": [ + { + "locationName": "[resourceGroup().location]", + "provisioningState": "Succeeded", + "failoverPriority": 0 + } + ], + "capabilities": [] + } + }, + { + "type": "microsoft.operationalinsights/workspaces", + "apiVersion": "2021-12-01-preview", + "name": "[variables('foxidsDefaultName')]", + "location": "[resourceGroup().location]", + "properties": { + "sku": { + "name": "pergb2018" + }, + "retentionInDays": 30, + "features": { + "enableLogAccessUsingOnlyResourcePermissions": true + }, + "workspaceCapping": { + "dailyQuotaGb": -1 + } + } + }, + { + "type": "microsoft.insights/components", + "apiVersion": "2020-02-02", + "name": "[variables('foxidsDefaultName')]", + "location": "[resourceGroup().location]", + "dependsOn": [ + "[resourceId('microsoft.operationalinsights/workspaces', variables('foxidsDefaultName'))]" + ], + "tags": { + "[concat('hidden-link:', resourceGroup().id, '/providers/Microsoft.Web/sites/', variables('foxidsDefaultName'))]": "Resource", + "[concat('hidden-link:', resourceGroup().id, '/providers/Microsoft.Web/sites/', variables('foxidsControlSiteName'))]": "Resource" + }, + "kind": "web", + "properties": { + "Application_Type": "web", + "Flow_Type": "Redfield", + "Request_Source": "IbizaAIExtension", + "DisableIpMasking": true, + "RetentionInDays": 90, + "WorkspaceResourceId": "[resourceId('microsoft.operationalinsights/workspaces', variables('foxidsDefaultName'))]", + "IngestionMode": "LogAnalytics" + } + }, + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2022-07-01", + "name": "[variables('foxidsDefaultName')]", + "location": "[resourceGroup().location]", + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('foxidsDefaultName'), 'subnet-data')]", + "[resourceId('Microsoft.Web/sites', variables('foxidsDefaultName'))]", + "[resourceId('Microsoft.Web/sites', variables('foxidsControlSiteName'))]" + ], + "properties": { + "sku": { + "family": "A", + "name": "[parameters('keyVaultSkuName')]" + }, + "tenantId": "[subscription().tenantId]", + "networkAcls": { + "bypass": "None", + "defaultAction": "Deny", + "ipRules": [], + "virtualNetworkRules": [ + { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('foxidsDefaultName'), 'subnet-data')]", + "ignoreMissingVnetServiceEndpoint": false + } + ] + }, + "accessPolicies": [ + { + "tenantId": "[subscription().tenantId]", + "objectId": "[reference(concat('Microsoft.Web/sites/', variables('foxidsDefaultName')), '2018-02-01', 'Full').identity.principalId]", + "permissions": { + "keys": [ + "Get", + "List", + "Decrypt", + "Sign" + ], + "secrets": [ + "get", + "List", + "Set" + ], + "certificates": [ + "Get", + "List", + "Create" + ] + } + }, + { + "tenantId": "[subscription().tenantId]", + "objectId": "[reference(concat('Microsoft.Web/sites/', variables('foxidsControlSiteName')), '2018-02-01', 'Full').identity.principalId]", + "permissions": { + "keys": [ + "Get", + "List" + ], + "secrets": [ + "get", + "List", + "Set", + "Delete" + ], + "certificates": [ + "Get", + "List", + "Create", + "Delete", + "Import", + "Update" + ] + } + } + ], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": true + } + }, + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2018-02-01", + "name": "[variables('foxidsDefaultName')]", + "location": "[resourceGroup().location]", + "sku": { + "name": "[parameters('appServicePlanSize')]", + "tier": "[parameters('appServicePlanSku')]", + "capacity": 1 + }, + "properties": { + "name": "[variables('foxidsDefaultName')]", + "workerSize": "0", + "numberOfWorkers": "1", + "reserved": true + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-01-01", + "name": "[variables('foxidsDefaultName')]", + "location": "[resourceGroup().location]", + "kind": "app,linux,container", + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('foxidsDefaultName'), 'subnet-data')]", + "[concat('Microsoft.Web/serverfarms/', variables('foxidsDefaultName'))]" + ], + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "name": "[variables('foxidsDefaultName')]", + "siteConfig": { + "linuxFxVersion": "DOCKER|foxids/foxids:latest", + "ftpsState": "Disabled", + "alwaysOn": true + }, + "reserved": true, + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('foxidsDefaultName'))]", + "clientAffinityEnabled": false, + "virtualNetworkSubnetId": "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('foxidsDefaultName'), 'subnet-data')]" + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-01-01", + "name": "[variables('foxidsControlSiteName')]", + "location": "[resourceGroup().location]", + "kind": "app,linux,container", + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('foxidsDefaultName'), 'subnet-data')]", + "[concat('Microsoft.Web/serverfarms/', variables('foxidsDefaultName'))]" + ], + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "name": "[variables('foxidsControlSiteName')]", + "siteConfig": { + "linuxFxVersion": "DOCKER|foxids/foxids-control:latest", + "ftpsState": "Disabled", + "alwaysOn": true + }, + "reserved": true, + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('foxidsDefaultName'))]", + "clientAffinityEnabled": false, + "virtualNetworkSubnetId": "[resourceId('Microsoft.Network/virtualNetworks/subnets', variables('foxidsDefaultName'), 'subnet-data')]" + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2023-01-01", + "name": "[concat(variables('foxidsDefaultName'), '/appsettings')]", + "dependsOn": [ + "[concat('Microsoft.Web/sites/', variables('foxidsDefaultName'))]", + "[resourceId('microsoft.insights/components', variables('foxidsDefaultName'))]", + "[concat('Microsoft.DocumentDB/databaseAccounts/', variables('foxidsDefaultName'))]", + "[concat('Microsoft.KeyVault/vaults/', variables('foxidsDefaultName'))]" + ], + "properties": { + "WEBSITES_ENABLE_APP_SERVICE_STORAGE": false, + "Settings__FoxIDsEndpoint": "[variables('foxidsSiteEndpoint')]", + "DOCKER_REGISTRY_SERVER_URL": "https://index.docker.io/v1", + "DOCKER_ENABLE_CI": true, + "ASPNETCORE_URLS": "http://+", + "Settings__UseHttp": true, + "Settings__TrustProxySchemeHeader": true, + "Settings__Options__Log": "ApplicationInsights", + "Settings__Options__DataStorage": "CosmosDb", + "Settings__Options__KeyStorage": "KeyVault", + "Settings__Options__Cache": "Memory", + "Settings__Options__DataCache": "None", + "ApplicationInsights__ConnectionString": "[reference(concat('microsoft.insights/components/', variables('foxidsDefaultName'))).ConnectionString]", + "Settings__CosmosDb__EndpointUri": "[reference(concat('Microsoft.DocumentDb/databaseAccounts/', variables('foxidsDefaultName'))).documentEndpoint]", + "Settings__KeyVault__EndpointUri": "[reference(concat('Microsoft.KeyVault/vaults/', variables('foxidsDefaultName'))).vaultUri]", + "Settings__Sendgrid__FromEmail": "[parameters('sendgridFromEmail')]" + } + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2023-01-01", + "name": "[concat(variables('foxidsControlSiteName'), '/appsettings')]", + "dependsOn": [ + "[concat('Microsoft.Web/sites/', variables('foxidsControlSiteName'))]", + "[resourceId('microsoft.insights/components', variables('foxidsDefaultName'))]", + "[concat('Microsoft.DocumentDB/databaseAccounts/', variables('foxidsDefaultName'))]", + "[concat('Microsoft.KeyVault/vaults/', variables('foxidsDefaultName'))]" + ], + "properties": { + "WEBSITES_ENABLE_APP_SERVICE_STORAGE": false, + "ApplicationInsights__ConnectionString": "[reference(concat('microsoft.insights/components/', variables('foxidsDefaultName'))).ConnectionString]", + "DOCKER_REGISTRY_SERVER_URL": "https://index.docker.io/v1", + "DOCKER_ENABLE_CI": true, + "ASPNETCORE_URLS": "http://+", + "Settings__UseHttp": true, + "Settings__TrustProxySchemeHeader": true, + "Settings__FoxIDsEndpoint": "[variables('foxidsSiteEndpoint')]", + "Settings__FoxIDsControlEndpoint": "[variables('foxidsControlSiteEndpoint')]", + "Settings__Options__Log": "ApplicationInsights", + "Settings__Options__DataStorage": "CosmosDb", + "Settings__Options__KeyStorage": "KeyVault", + "Settings__Options__Cache": "Memory", + "Settings__Options__DataCache": "None", + "Settings__MasterSeedEnabled": true, + "Settings__CosmosDb__EndpointUri": "[reference(concat('Microsoft.DocumentDb/databaseAccounts/', variables('foxidsDefaultName'))).documentEndpoint]", + "Settings__KeyVault__EndpointUri": "[reference(concat('Microsoft.KeyVault/vaults/', variables('foxidsDefaultName'))).vaultUri]", + "Settings__ApplicationInsights__WorkspaceId": "[reference(concat('microsoft.operationalinsights/workspaces/', variables('foxidsDefaultName'))).customerId]" + } + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2016-10-01", + "name": "[concat(variables('foxidsDefaultName'), '/Settings--CosmosDb--PrimaryKey')]", + "dependsOn": [ + "[concat('Microsoft.KeyVault/vaults/', variables('foxidsDefaultName'))]", + "[concat('Microsoft.DocumentDB/databaseAccounts/', variables('foxidsDefaultName'))]" + ], + "properties": { + "value": "[listKeys(resourceId('Microsoft.DocumentDB/databaseAccounts', variables('foxidsDefaultName')), '2015-11-06').primaryMasterKey]" + } + }, + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2016-10-01", + "name": "[concat(variables('foxidsDefaultName'), '/Settings--Sendgrid--ApiKey')]", + "dependsOn": [ + "[concat('Microsoft.KeyVault/vaults/', variables('foxidsDefaultName'))]" + ], + "properties": { + "value": "[parameters('sendgridApiKey')]" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2018-09-01-preview", + "name": "[guid(uniqueString(variables('foxidsDefaultName'), 'read', variables('foxidsControlSiteName')))]", + "scope": "[format('microsoft.operationalinsights/workspaces/{0}', variables('foxidsDefaultName'))]", + "properties": { + "roleDefinitionId": "[resourceId('Microsoft.Authorization/roleDefinitions', 'acdd72a7-3385-48ef-bd42-f606fba81ae7')]", + "principalId": "[reference(concat('Microsoft.Web/sites/', variables('foxidsControlSiteName')), '2018-02-01', 'Full').identity.principalId]", + "principalType": "ServicePrincipal" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', variables('foxidsControlSiteName'))]", + "[resourceId('microsoft.operationalinsights/workspaces', variables('foxidsDefaultName'))]" + ] + }, + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2022-01-01", + "name": "[variables('foxidsDefaultName')]", + "location": "[resourceGroup().location]", + "properties": { + "addressSpace": { + "addressPrefixes": [ + "10.1.0.0/16" + ] + }, + "subnets": [ + { + "name": "subnet-data", + "properties": { + "addressPrefix": "10.1.0.0/24", + "serviceEndpoints": [ + { + "service": "Microsoft.AzureCosmosDB", + "locations": [ + "[resourceGroup().location]" + ] + }, + { + "service": "Microsoft.KeyVault", + "locations": [ + "[resourceGroup().location]" + ] + } + ], + "privateEndpointNetworkPolicies": "Disabled", + "privateLinkServiceNetworkPolicies": "Enabled" + }, + "type": "Microsoft.Network/virtualNetworks/subnets" + } + ] + } + }, + { + "type": "Microsoft.Network/virtualNetworks/subnets", + "apiVersion": "2022-01-01", + "name": "[concat(variables('foxidsDefaultName'), '/subnet-data')]", + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks', variables('foxidsDefaultName'))]" + ], + "properties": { + "addressPrefix": "10.1.0.0/24", + "serviceEndpoints": [ + { + "service": "Microsoft.AzureCosmosDB", + "locations": [ + "[resourceGroup().location]" + ] + }, + { + "service": "Microsoft.KeyVault", + "locations": [ + "[resourceGroup().location]" + ] + } + ], + "delegations": [ + { + "name": "delegation", + "id": "[concat(resourceId('Microsoft.Network/virtualNetworks/subnets', variables('foxidsDefaultName'), 'subnet-data'), '/delegations/delegation')]", + "properties": { + "serviceName": "Microsoft.Web/serverfarms" + }, + "type": "Microsoft.Network/virtualNetworks/subnets/delegations" + } + ], + "privateEndpointNetworkPolicies": "Enabled", + "privateLinkServiceNetworkPolicies": "Enabled" + } + }, + { + "type": "microsoft.insights/privatelinkscopes", + "apiVersion": "2021-07-01-preview", + "name": "[variables('foxidsDefaultName')]", + "location": "global", + "properties": { + "accessModeSettings": { + "exclusions": [], + "queryAccessMode": "PrivateOnly", + "ingestionAccessMode": "PrivateOnly" + } + } + }, + { + "type": "microsoft.insights/privatelinkscopes/scopedresources", + "apiVersion": "2021-07-01-preview", + "name": "[concat(variables('foxidsDefaultName'), '/scoped-', variables('foxidsDefaultName'), '-insights')]", + "dependsOn": [ + "[resourceId('microsoft.insights/privatelinkscopes', variables('foxidsDefaultName'))]", + "[resourceId('microsoft.insights/components', variables('foxidsDefaultName'))]" + ], + "properties": { + "linkedResourceId": "[resourceId('microsoft.insights/components', variables('foxidsDefaultName'))]" + } + }, + { + "type": "microsoft.insights/privatelinkscopes/scopedresources", + "apiVersion": "2021-07-01-preview", + "name": "[concat(variables('foxidsDefaultName'), '/scoped-', variables('foxidsDefaultName'), '-workspaces')]", + "dependsOn": [ + "[resourceId('microsoft.insights/privatelinkscopes', variables('foxidsDefaultName'))]", + "[resourceId('microsoft.operationalinsights/workspaces', variables('foxidsDefaultName'))]" + ], + "properties": { + "linkedResourceId": "[resourceId('microsoft.operationalinsights/workspaces', variables('foxidsDefaultName'))]" + } + }, + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2018-09-01", + "name": "privatelink.oms.opinsights.azure.com", + "location": "global", + "properties": { + "maxNumberOfRecordSets": 25000, + "maxNumberOfVirtualNetworkLinks": 1000, + "maxNumberOfVirtualNetworkLinksWithRegistration": 100 + } + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2018-09-01", + "name": "[concat('privatelink.oms.opinsights.azure.com', '/link_to_', variables('foxidsDefaultName'))]", + "location": "global", + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.oms.opinsights.azure.com')]", + "[resourceId('Microsoft.Network/virtualNetworks', variables('foxidsDefaultName'))]" + ], + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', variables('foxidsDefaultName'))]" + } + } + }, + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2018-09-01", + "name": "privatelink.ods.opinsights.azure.com", + "location": "global", + "properties": { + "maxNumberOfRecordSets": 25000, + "maxNumberOfVirtualNetworkLinks": 1000, + "maxNumberOfVirtualNetworkLinksWithRegistration": 100 + } + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2018-09-01", + "name": "[concat('privatelink.ods.opinsights.azure.com', '/link_to_', variables('foxidsDefaultName'))]", + "location": "global", + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.ods.opinsights.azure.com')]", + "[resourceId('Microsoft.Network/virtualNetworks', variables('foxidsDefaultName'))]" + ], + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', variables('foxidsDefaultName'))]" + } + } + }, + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2018-09-01", + "name": "privatelink.agentsvc.azure-automation.net", + "location": "global", + "properties": { + "maxNumberOfRecordSets": 25000, + "maxNumberOfVirtualNetworkLinks": 1000, + "maxNumberOfVirtualNetworkLinksWithRegistration": 100 + } + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2018-09-01", + "name": "[concat('privatelink.agentsvc.azure-automation.net', '/link_to_', variables('foxidsDefaultName'))]", + "location": "global", + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.agentsvc.azure-automation.net')]", + "[resourceId('Microsoft.Network/virtualNetworks', variables('foxidsDefaultName'))]" + ], + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', variables('foxidsDefaultName'))]" + } + } + }, + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2018-09-01", + "name": "privatelink.blob.core.windows.net", + "location": "global", + "properties": { + "maxNumberOfRecordSets": 25000, + "maxNumberOfVirtualNetworkLinks": 1000, + "maxNumberOfVirtualNetworkLinksWithRegistration": 100 + } + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2018-09-01", + "name": "[concat('privatelink.blob.core.windows.net', '/link_to_', variables('foxidsDefaultName'))]", + "location": "global", + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.blob.core.windows.net')]", + "[resourceId('Microsoft.Network/virtualNetworks', variables('foxidsDefaultName'))]" + ], + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', variables('foxidsDefaultName'))]" + } + } + } + ] +} \ No newline at end of file diff --git a/azuredeploy.json b/azuredeploy.json index 1c40beff9..73a23386d 100644 --- a/azuredeploy.json +++ b/azuredeploy.json @@ -3,10 +3,9 @@ "contentVersion": "1.0.0.0", "parameters": { "appServicePlanSize": { - "defaultValue": "P1V2", + "defaultValue": "P1V3", "allowedValues": [ "F1", - "D1", "B1", "B2", "B3", @@ -16,12 +15,10 @@ "P1V2", "P2V2", "P3V2", + "P0V3", "P1V3", "P2V3", - "P3V3", - "P1", - "P2", - "P3" + "P3V3" ], "type": "string", "metadata": { diff --git a/docs/app-reg-oidc.md b/docs/app-reg-oidc.md index 6ab2f3245..b7b2ae781 100644 --- a/docs/app-reg-oidc.md +++ b/docs/app-reg-oidc.md @@ -8,10 +8,10 @@ Your application become a Relying Party (RP) and FoxIDs acts as an OpenID Provid FoxIDs support [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) where your application can discover the OpenID Provider. -FoxIDs support [OpenID Connect authentication](https://openid.net/specs/openid-connect-core-1_0.html#Authentication) (login), [RP-initiated logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) and [front-channel logout](https://openid.net/specs/openid-connect-frontchannel-1_0.html). A session is established when the user authenticates and the session id is included in the id token. The session is invalidated on logout. +FoxIDs support [OpenID Connect authentication](https://openid.net/specs/openid-connect-core-1_0.html#Authentication) (login), [RP-initiated logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) and [front-channel logout](https://openid.net/specs/openid-connect-frontchannel-1_0.html). A session is established when the user authenticates and the session id is included in the ID token. The session is invalidated on logout. FoxIDs can show a logout confirmation dialog depending on configuration and rather an ID token is included in the logout request or not. -Default both id token and access token are issued with the client's client id as the audience. The default resource can be removed from the access token in FoxIDs Control. +Default both ID token and access token are issued with the client's client id as the audience. The default resource can be removed from the access token in FoxIDs Control. Access tokens can be issued with a list of audiences and thereby be issued to multiple APIs defined in FoxIDs as [OAuth 2.0 resources](app-reg-oauth-2.0.md#oauth-20-resource). The application can then call an API securing the call with the access token using the [The OAuth 2.0 Authorization Framework: Bearer Token Usage](https://datatracker.ietf.org/doc/html/rfc6750). diff --git a/docs/bridge.md b/docs/bridge.md index 5325e8c8c..bb25f2bc8 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -8,7 +8,7 @@ A log in request from your app is routed as an external SAML 2.0 log in requests ![Bridge SAML 2.0 to OpenID Connect](images/bridge-saml-oidc.svg) The opposite is likewise possible starting the log in request from a [SAML 2.0 application registration](app-reg-saml-2.0.md) app and routing to an external OpenID Provider (OP) configured as a [OpenID Connect authentication method](auth-method-oidc.md). -Subsequently, the response is mapped to a SAML 2.0 response. +Subsequently, the response is converted to a SAML 2.0 response. ![Bridge OpenID Connect to SAML 2.0](images/bridge-oidc-saml.svg) diff --git a/docs/control.md b/docs/control.md index c1b0e6329..c500b0ea8 100644 --- a/docs/control.md +++ b/docs/control.md @@ -60,46 +60,45 @@ The environment properties can be configured by clicking the top right setting i ![Configure environment settings](images/configure-environment-setting.png) ## FoxIDs Control API -Control API is a REST API and has a Swagger (OpenApi) interface description. +Control API is a REST API and has a [Swagger (OpenApi)](https://control.foxids.com/api/swagger/v1/swagger.json) interface description. -Control API require that the client calling the API is granted the `foxids:master` scope to access master tenant data or the `foxids:tenant` scope to access tenant data in a particular tenant. Normally only tenant data is accessed. +Control API require that the client calling the API is granted appropriate [access rights](#api-access-rights) by scopes and roles. - - The API can be accessed with a OAuth 2.0 client. Where the client is granted the administrator role `foxids:tenant.admin` acting as the client itself using client credentials grant. - It is probably helpful to take a look at how the [sample seed tool](samples.md#configure-the-sample-seed-tool) client is granted access. - - Or the API can be accessed with a OpenID Connect client with an authenticated master environment user. Where the user is granted the administrator role `foxids:tenant.admin`. - *As an advanced option the mater user can also be granted access via a trust.* - -This shows the Control API configuration in a tenants master environment with a scope that grants access to tenant data. +This shows the Control API configuration in a tenants master environment with a default set of scopes that grants access to tenants data. ![Configure foxids_control_api](images/configure-foxids_control_api.png) +More scopes can be added to extend the [API access rights](#api-access-rights) for the different environments. To achieve least privileges access rights for each environment. + Control API is called with an access token as described in the [OAuth 2.0 Bearer Token (RFC 6750)](https://datatracker.ietf.org/doc/html/rfc6750) standard. -The Swagger (OpenApi) interface document is exposed on `.../api/swagger/v1/swagger.json`. +If you host FoxIDs your self the Swagger (OpenApi) interface document is exposed in FoxIDs Control on `.../api/swagger/v1/swagger.json`. > FoxIDs.com Swagger (OpenApi) [https://control.foxids.com/api/swagger/v1/swagger.json](https://control.foxids.com/api/swagger/v1/swagger.json) The Control API URL contains the tenant name and environment name on winch you want to operate `.../[tenant_name]/[environment_name]/...`. To call the API you replace the `[tenant_name]` element with your tenant name and the `[environment_name]` element with the environment name of the environment you want to call. -If you e.g. want read a OpenID Connect application registration on FoxIDs.com with the name `some_oidc_app` you do a HTTP GET call to `https://control.foxids.com/api/[tenant_name]/[environment_name]/!oidcdownparty?name=some_oidc_app` - replaced with your tenant and environment names. +If you e.g. want to read a OpenID Connect application registration on FoxIDs.com with the name `some_oidc_app` you do a HTTP GET call to `https://control.foxids.com/api/[tenant_name]/[environment_name]/!oidcdownparty?name=some_oidc_app` - replaced with your tenant and environment names. ### API access rights Access to Control API is limited by scopes and roles. There are two sets of scopes based on `foxids:master` which grant access to the master tenant data and `foxids:tenant` which grant access to tenant data. -The Control API resource `foxids_control_api` is defined in each tenant's master environment and the configured set of scopes grant access the tenants data in the Control API. +The Control API resource `foxids_control_api` is defined in each tenant's master environment and the configured set of scopes grant access the tenants data through the Control API. -A scopes access is limited by adding more elements separated with semicolon and dot. The dot notation limits or grant a sub role, the notation is both used in scopes and roles. +A scopes access is limited by adding more elements separated with semicolon and dot. The dot notation limits to a specific sub role, the notation is both used in scopes and roles. To be granted access the caller is required to possess one or more matching scope(s) and role(s). Each access right is both defined as a scope and a role. This makes it possible to limit or grant access on both client and user level. The access rights are a hierarchy and the client and user do not need to be granted matching scopes and roles. -The administrator role `foxids:tenant.admin` grants access to all data in a tenant and the master tenant data, it is the same as having the role `foxids:tenant` and `foxids:master`. +The administrator role `foxids:tenant.admin` grants access to all data in a tenant and the master tenant data, it is the same as having the roles `foxids:tenant` and `foxids:master`. > A client request a scope by requesting a scope on a resource, separating the resource and scope with a semicolon. E.g., to request the `foxids:tenant:track:party.create` scope the client request for `foxids_control_api:foxids:tenant:track:party.create`. #### Tenant access rights The tenant access rights is at the same time both scopes and roles. +> If the scope you need is not defined on the Control API `foxids_control_api` you can add the scope. The same goes for roles which has to be defined on the user or the calling client. + The `:track[xxxx]` specifies a tenant e.g., the `dev` tenant is `:track[dev]`. @@ -395,5 +394,3 @@ The master tenant access rights is at the same time both scopes and roles.
read
- -If the scope you need is not defined on the Control API `foxids_control_api` you can add the scope. The same goes for roles which has to be defined on the user or the calling client. \ No newline at end of file diff --git a/docs/deployment-k8s.md b/docs/deployment-k8s.md index 62e122c09..ccacb691f 100644 --- a/docs/deployment-k8s.md +++ b/docs/deployment-k8s.md @@ -7,9 +7,9 @@ This is a description of how to make a default [deployment](#deployment), [log i Pre requirements: - You have a Kubernetes cluster or Docker Desktop with Kubernetes enabled. - You have basic knowledge about Kubernetes. -- You have `kubectl` installer on your workstation. -- You have [Helm](https://docs.helm.sh/) installer on your workstation and your cluster. - Install Helm on windows with this CMD command `winget install Helm.Helm` +- You have `kubectl` installer. +- You have [Helm](https://docs.helm.sh/) installer. + *Install Helm on windows with this CMD command `winget install Helm.Helm`* > This is a list of [useful commands](#useful-commands) in the end of this description. diff --git a/docs/howto-connect.md b/docs/howto-connect.md index 27d02c091..7a2f458a4 100644 --- a/docs/howto-connect.md +++ b/docs/howto-connect.md @@ -36,6 +36,8 @@ All IdPs supporting either OpenID Connect or SAML 2.0 can be connected to FoxIDs Configure [OpenID Connect](auth-method-oidc.md) which trust an external OpenID Provider (OP) - *an Identity Provider (IdP) is called an OpenID Provider (OP) if configured with OpenID Connect*. +> You should always ask for the `sub` claim, even if you use the `email` claim or e.g. another custom user ID claim. + How to guides: - Connect [IdentityServer](auth-method-howto-oidc-identityserver.md) @@ -50,6 +52,9 @@ How to guides: Configure [SAML 2.0](auth-method-saml-2.0.md) which trust an external Identity Provider (IdP). +> You should always ask for the `NameID` claim, even if you use the email (`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`) claim or e.g. another custom user ID claim. SAML 2.0 can not do logout without the `NameID` claim. +> You should prefer to do SAML 2.0 connects with the use of the authentication methods metadata, then the customer's IdP can automatically download the certificate(s). And request for an online IdP metadata from the customer. + How to guides: - Connect [PingIdentity / PingOne](auth-method-howto-saml-2.0-pingone.md) diff --git a/docs/howto-saml-2.0-context-handler.md b/docs/howto-saml-2.0-context-handler.md index 1ee3df80a..214a06679 100644 --- a/docs/howto-saml-2.0-context-handler.md +++ b/docs/howto-saml-2.0-context-handler.md @@ -228,22 +228,20 @@ Create the claims which has to be issued to Context Handler in claim transforms. 2. Select the **Claim transforms** tab 3. Add a **Constant** claim `https://data.gov.dk/model/core/specVersion` with the value `OIO-SAML-3.0` 4. Add a **Constant** claim `https://data.gov.dk/model/core/kombitSpecVer` with the value `2.0` - -5. Add the spec. ver. `https://data.gov.dk/model/core/specVersion` and Kombit spec. ver. `https://data.gov.dk/model/core/kombitSpecVer` as constant claims -6. Add a **Constant** levels of assurance (loa) claim `https://data.gov.dk/concept/core/nsis/loa` with e.g. the value `Substantial` or read the claim through the claims pipeline +5. Add a **Constant** levels of assurance (loa) claim `https://data.gov.dk/concept/core/nsis/loa` with e.g. the value `Substantial` or read the claim through the claims pipeline ![Context Handler SAML 2.0 application registration](images/howto-saml-context-handler-app-ct1.png) -7. Add a **Concatenated** claim to replace the NameID `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` claim which a concatenated version of the CVR number, display name and unique user ID -8. Select **Action** **Replace claim** -9. Concatenate claims: +6. Add a **Concatenated** claim to replace the NameID `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` claim which a concatenated version of the CVR number, display name and unique user ID +7. Select **Action** **Replace claim** +8. Concatenate claims: - `https://data.gov.dk/model/core/eid/professional/cvr` - `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` - `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname` - `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` -10. Set the **Concatenate format string** to `C=DK,O={0},CN={1} {2},Serial={3}` +9. Set the **Concatenate format string** to `C=DK,O={0},CN={1} {2},Serial={3}` ![Context Handler SAML 2.0 application registration](images/howto-saml-context-handler-app-ct2.png) -11. Click **Update** +10. Click **Update** **4 - Add SAML 2.0 claim to JWT claim mappings in [FoxIDs Control Client](control.md#foxids-control-client)** diff --git a/docs/images/configure-foxids_control_api.png b/docs/images/configure-foxids_control_api.png index d0f943a69..1942237de 100644 Binary files a/docs/images/configure-foxids_control_api.png and b/docs/images/configure-foxids_control_api.png differ diff --git a/docs/name-title-icon-css.md b/docs/name-title-icon-css.md index a87505b04..673e2cc12 100644 --- a/docs/name-title-icon-css.md +++ b/docs/name-title-icon-css.md @@ -12,7 +12,9 @@ The name is configured in the environment settings in [FoxIDs Control Client](co ## Add browser title, browser icon and CSS -The FoxIDs user interface can be customized per [login authentication method](login). This means that a single FoxIDs environment can support multiple user interface designs with different browser titles, browser icons and CSS. +The FoxIDs user interface can be customised per [login authentication method](login). This means that a single FoxIDs environment can support multiple user interface designs with different browser titles, browser icons and CSS. + +If you do not specify a login authentication method as an allowed authentication method in your application. The default login authentication method is used, and also whatever is customised to it. > FoxIDs use Bootstrap 4.6 and Flexbox CSS. diff --git a/src/FoxIDs.Control/Controllers/Base/ApiController.cs b/src/FoxIDs.Control/Controllers/Base/ApiController.cs index d8b313843..ccdc4b970 100644 --- a/src/FoxIDs.Control/Controllers/Base/ApiController.cs +++ b/src/FoxIDs.Control/Controllers/Base/ApiController.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Routing; using System; using System.Linq; +using System.Runtime.CompilerServices; namespace FoxIDs.Controllers { @@ -49,7 +50,7 @@ public virtual CreatedResult Created(object queryValues, object value) return new CreatedResult(uriBuilder.Uri, value); } - public virtual NotFoundObjectResult NotFound(string typeName, string name, string fieldName = null) + public virtual NotFoundObjectResult NotFound(string typeName, string name, string fieldName = null, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) { try { @@ -57,7 +58,7 @@ public virtual NotFoundObjectResult NotFound(string typeName, string name, strin } catch (Exception ex) { - logger.Warning(ex); + logger.Warning(ex, GetMessage("Not found", memberName, sourceFilePath, sourceLineNumber)); if (!fieldName.IsNullOrWhiteSpace()) { Response.Headers["field"] = fieldName; @@ -66,7 +67,7 @@ public virtual NotFoundObjectResult NotFound(string typeName, string name, strin } } - public virtual ConflictObjectResult Conflict(string typeName, string name, string fieldName = null) + public virtual ConflictObjectResult Conflict(string typeName, string name, string fieldName = null, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) { try { @@ -74,7 +75,7 @@ public virtual ConflictObjectResult Conflict(string typeName, string name, strin } catch (Exception ex) { - logger.Warning(ex); + logger.Warning(ex, GetMessage("Conflict", memberName, sourceFilePath, sourceLineNumber)); if (!fieldName.IsNullOrWhiteSpace()) { Response.Headers["field"] = fieldName; @@ -83,7 +84,7 @@ public virtual ConflictObjectResult Conflict(string typeName, string name, strin } } - public BadRequestObjectResult BadRequest(ModelStateDictionary modelState, Exception innerEx = null) + public BadRequestObjectResult BadRequest(ModelStateDictionary modelState, Exception innerEx = null, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) { try { @@ -92,7 +93,7 @@ public BadRequestObjectResult BadRequest(ModelStateDictionary modelState, Except } catch (Exception ex) { - logger.Error(ex); + logger.Error(ex, GetMessage("Bad request", memberName, sourceFilePath, sourceLineNumber)); if (modelState.Keys.Count() == 1) { Response.Headers["field"] = modelState.Keys.First(); @@ -100,5 +101,10 @@ public BadRequestObjectResult BadRequest(ModelStateDictionary modelState, Except return base.BadRequest(ex.Message); } } + + private string GetMessage(string message, string memberName, string sourceFilePath, int sourceLineNumber) + { + return $"{message} at {memberName} in {sourceFilePath}:line {sourceLineNumber}"; + } } } diff --git a/src/FoxIDs.Control/Controllers/Client/MClientSettingsController.cs b/src/FoxIDs.Control/Controllers/Client/MClientSettingsController.cs index 4e33e0694..6b77dca58 100644 --- a/src/FoxIDs.Control/Controllers/Client/MClientSettingsController.cs +++ b/src/FoxIDs.Control/Controllers/Client/MClientSettingsController.cs @@ -33,7 +33,10 @@ public MClientSettingsController(FoxIDsControlSettings settings, TelemetryScoped Version = version.ToString(2), FullVersion = version.ToString(3), LogOption = mapper.Map(settings.Options.Log), - KeyStorageOption = mapper.Map(settings.Options.KeyStorage) + KeyStorageOption = mapper.Map(settings.Options.KeyStorage), + EnablePayment = settings.Payment?.EnablePayment == true && settings.Usage?.EnableInvoice == true, + PaymentTestMode = settings.Payment != null ? settings.Payment.TestMode : true, + MollieProfileId = settings.Payment?.MollieProfileId, }); } } diff --git a/src/FoxIDs.Control/Controllers/Client/WController.cs b/src/FoxIDs.Control/Controllers/Client/WController.cs index 6c6278e2f..dd853d8d6 100644 --- a/src/FoxIDs.Control/Controllers/Client/WController.cs +++ b/src/FoxIDs.Control/Controllers/Client/WController.cs @@ -8,6 +8,7 @@ using FoxIDs.Models; using FoxIDs.Infrastructure; using Microsoft.AspNetCore.Http; +using FoxIDs.Models.Config; namespace FoxIDs.Controllers.Client { @@ -16,11 +17,13 @@ public class WController : Controller private static string indexFile; private readonly TelemetryScopedLogger logger; private readonly IWebHostEnvironment currentEnvironment; + private readonly FoxIDsControlSettings settings; - public WController(TelemetryScopedLogger logger, IWebHostEnvironment environment) + public WController(TelemetryScopedLogger logger, IWebHostEnvironment environment, FoxIDsControlSettings settings) { this.logger = logger; currentEnvironment = environment; + this.settings = settings; } [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] @@ -83,6 +86,14 @@ private IActionResult GetProcessedIndexFile(string technicalError = null) var file = currentEnvironment.WebRootFileProvider.GetFileInfo("index.html"); indexFile = System.IO.File.ReadAllText(file.PhysicalPath); indexFile = indexFile.Replace("{version}", GetBuildDate()); + if(settings.Payment?.EnablePayment == true && settings.Usage?.EnableInvoice == true) + { + indexFile = indexFile.Replace("{payment_script}", ""); + } + else + { + indexFile = indexFile.Replace("{payment_script}", string.Empty); + } } return Content(AddErrorInfo(indexFile, technicalError), "text/HTML"); } diff --git a/src/FoxIDs.Control/Controllers/Helpers/TDownPartyTestController.cs b/src/FoxIDs.Control/Controllers/Helpers/TDownPartyTestController.cs index 2d179711b..9702d9eef 100644 --- a/src/FoxIDs.Control/Controllers/Helpers/TDownPartyTestController.cs +++ b/src/FoxIDs.Control/Controllers/Helpers/TDownPartyTestController.cs @@ -101,6 +101,7 @@ public TDownPartyTestController(FoxIDsControlSettings settings, TelemetryScopedL IsTest = true, TestUrl = testUrl, TestExpireAt = DateTimeOffset.UtcNow.AddSeconds(settings.DownPartyTestLifetime).ToUnixTimeSeconds(), + TestExpireInSeconds = settings.DownPartyTestLifetime, Nonce = authenticationRequest.Nonce, CodeVerifier = codeVerifier, AllowUpParties = testDownPartyRequest.UpParties.Select(p => new UpPartyLink { Name = p.Name.ToLower(), ProfileName = p.ProfileName?.ToLower() }).ToList(), @@ -151,6 +152,7 @@ public TDownPartyTestController(FoxIDsControlSettings settings, TelemetryScopedL DisplayName = mParty.DisplayName, TestUrl = testUrl, TestExpireAt = mParty.TestExpireAt.Value, + TestExpireInSeconds = mParty.TestExpireInSeconds.Value, }); } catch (ValidationException) diff --git a/src/FoxIDs.Control/Controllers/Helpers/TPlanInfoController.cs b/src/FoxIDs.Control/Controllers/Helpers/TPlanInfoController.cs new file mode 100644 index 000000000..3e2a1bf63 --- /dev/null +++ b/src/FoxIDs.Control/Controllers/Helpers/TPlanInfoController.cs @@ -0,0 +1,54 @@ +using FoxIDs.Infrastructure; +using Api = FoxIDs.Models.Api; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using FoxIDs.Infrastructure.Security; +using FoxIDs.Repository; +using FoxIDs.Models; +using AutoMapper; +using System.Collections.Generic; +using System.Linq; + +namespace FoxIDs.Controllers +{ + [TenantScopeAuthorize(Constants.ControlApi.Segment.Base, Constants.ControlApi.Segment.Party)] + public class TPlanInfoController : ApiController + { + private readonly IMapper mapper; + private readonly IMasterDataRepository masterDataRepository; + + public TPlanInfoController(TelemetryScopedLogger logger, IMapper mapper, IMasterDataRepository masterDataRepository) : base(logger) + { + this.mapper = mapper; + this.masterDataRepository = masterDataRepository; + } + + /// + /// Get list of plans. + /// + /// Client settings. + [ProducesResponseType(typeof(HashSet), StatusCodes.Status200OK)] + public async Task>> GetPlanInfo() + { + try + { + var mPlans = await masterDataRepository.GetListAsync(); + var aPlans = new HashSet(mPlans.Count()); + foreach (var mPlan in mPlans.OrderBy(p => p.CostPerMonth).ThenBy(t => t.Name)) + { + aPlans.Add(mapper.Map(mPlan)); + } + return Ok(aPlans); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.NotFound) + { + return Ok(new HashSet()); + } + throw; + } + } + } +} diff --git a/src/FoxIDs.Control/Controllers/Master/MPlanController.cs b/src/FoxIDs.Control/Controllers/Master/MPlanController.cs index 91989d1b3..c162a72e1 100644 --- a/src/FoxIDs.Control/Controllers/Master/MPlanController.cs +++ b/src/FoxIDs.Control/Controllers/Master/MPlanController.cs @@ -4,7 +4,6 @@ using FoxIDs.Repository; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using System.Net; using System.Threading.Tasks; using AutoMapper; using System.Linq; @@ -77,11 +76,15 @@ public MPlanController(TelemetryScopedLogger logger, IMapper mapper, ITenantData if (!await ModelState.TryValidateObjectAsync(plan)) return BadRequest(ModelState); plan.Name = plan.Name.ToLower(); + var count = await masterDataRepository.CountAsync(); + if (count >= Constants.Models.Plan.PlansMax) + { + throw new Exception($"Maximum number of plans ({Constants.Models.Plan.PlansMax}) has been reached."); + } + var mPlan = mapper.Map(plan); await masterDataRepository.CreateAsync(mPlan); - await planCacheLogic.InvalidatePlanCacheAsync(plan.Name); - return Created(mapper.Map(mPlan)); } catch (FoxIDsDataException ex) diff --git a/src/FoxIDs.Control/Controllers/Master/MUsageSettingsController.cs b/src/FoxIDs.Control/Controllers/Master/MUsageSettingsController.cs new file mode 100644 index 000000000..9a1e4b55b --- /dev/null +++ b/src/FoxIDs.Control/Controllers/Master/MUsageSettingsController.cs @@ -0,0 +1,106 @@ +using FoxIDs.Infrastructure; +using FoxIDs.Models; +using Api = FoxIDs.Models.Api; +using FoxIDs.Repository; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using AutoMapper; +using FoxIDs.Infrastructure.Filters; +using FoxIDs.Infrastructure.Security; +using System.Collections.Generic; + +namespace FoxIDs.Controllers +{ + [RequireMasterTenant] + [MasterScopeAuthorize] + public class MUsageSettingsController : ApiController + { + private readonly TelemetryScopedLogger logger; + private readonly IMapper mapper; + private readonly IMasterDataRepository masterDataRepository; + + public MUsageSettingsController(TelemetryScopedLogger logger, IMapper mapper, IMasterDataRepository masterDataRepository) : base(logger) + { + this.logger = logger; + this.mapper = mapper; + this.masterDataRepository = masterDataRepository; + } + + /// + /// Get usage settings. + /// + /// Usage settings. + [ProducesResponseType(typeof(Api.UsageSettings), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetUsageSettings() + { + try + { + var mUsageSettings = await LoadAndCreateUsageSettings(); + return Ok(mapper.Map(mUsageSettings)); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.NotFound) + { + logger.Warning(ex, $"NotFound, Get '{typeof(Api.UsageSettings).Name}'."); + return NotFound(typeof(Api.UsageSettings).Name, "default"); + } + throw; + } + } + + /// + /// Update usage settings. + /// + /// Usage settings. + /// UsageSettings. + [ProducesResponseType(typeof(Api.UsageSettings), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PutUsageSettings([FromBody] Api.UsageSettings usageSettings) + { + try + { + if (!await ModelState.TryValidateObjectAsync(usageSettings)) return BadRequest(ModelState); + + var mUsageSettings = await LoadAndCreateUsageSettings(); + mUsageSettings.CurrencyExchanges = mapper.Map>(usageSettings.CurrencyExchanges); + foreach (var currencyExchange in mUsageSettings.CurrencyExchanges) + { + currencyExchange.Currency = currencyExchange.Currency.ToUpper(); + } + mUsageSettings.HourPrice = usageSettings.HourPrice; + mUsageSettings.InvoiceNumber = usageSettings.InvoiceNumber; + mUsageSettings.InvoiceNumberPrefix = usageSettings.InvoiceNumberPrefix; + await masterDataRepository.UpdateAsync(mUsageSettings); + + return Ok(mapper.Map(mUsageSettings)); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.NotFound) + { + logger.Warning(ex, $"NotFound, Update '{typeof(Api.UsageSettings).Name}'."); + return NotFound(typeof(Api.UsageSettings).Name, "default"); + } + throw; + } + } + + private async Task LoadAndCreateUsageSettings() + { + var mUsageSettings = await masterDataRepository.GetAsync(await UsageSettings.IdFormatAsync(), required: false); + if (mUsageSettings == null) + { + mUsageSettings = new UsageSettings + { + Id = await UsageSettings.IdFormatAsync() + }; + await masterDataRepository.CreateAsync(mUsageSettings); + } + + return mUsageSettings; + } + } +} diff --git a/src/FoxIDs.Control/Controllers/Parties/GenericPartyApiController.cs b/src/FoxIDs.Control/Controllers/Parties/GenericPartyApiController.cs index a484aea48..6c3b18cbb 100644 --- a/src/FoxIDs.Control/Controllers/Parties/GenericPartyApiController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/GenericPartyApiController.cs @@ -12,6 +12,7 @@ using FoxIDs.Models.Config; using System.Collections.Generic; using System.Linq; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { @@ -205,7 +206,27 @@ protected async Task> Put(AParty party, Func 0) + { + mOidcDownParty.TestExpireInSeconds = downPartyTestLifetime; + var newTestExpireAt = DateTimeOffset.UtcNow.AddSeconds(downPartyTestLifetime).ToUnixTimeSeconds(); + if (newTestExpireAt > tempMParty.TestExpireAt) + { + mOidcDownParty.TestExpireAt = newTestExpireAt; + } + else + { + mOidcDownParty.TestExpireAt = tempMParty.TestExpireAt; + } + } + else + { + mOidcDownParty.TestExpireInSeconds = 0; + mOidcDownParty.TestExpireAt = -1; + } + mOidcDownParty.CodeVerifier = tempMParty.CodeVerifier; mOidcDownParty.Nonce = tempMParty.Nonce; } diff --git a/src/FoxIDs.Control/Controllers/Parties/TExternalLoginUpPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TExternalLoginUpPartyController.cs index 5caa36867..d01e53c2a 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TExternalLoginUpPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TExternalLoginUpPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Parties/TFilterUpPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TFilterUpPartyController.cs index a26f90486..d4c541717 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TFilterUpPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TFilterUpPartyController.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; -using System.Net; using System.Collections.Generic; using System.Linq; using ITfoxtec.Identity; @@ -33,25 +32,18 @@ public TFilterUpPartyController(TelemetryScopedLogger logger, IMapper mapper, IT /// /// Filter authentication method. /// - /// Filter authentication method name. + /// Filter authentication method by name. + /// Filter authentication method by HRD domains. /// Authentication methods. [ProducesResponseType(typeof(HashSet), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task>> GetFilterUpParty(string filterName) + public async Task>> GetFilterUpParty(string filterName, string filterHrdDomains) { try { - var doFilterPartyType = Enum.TryParse(filterName, out var filterPartyType); - var idKey = new Track.IdKey { TenantName = RouteBinding.TenantName, TrackName = RouteBinding.TrackName }; - (var mUpPartys, _) = filterName.IsNullOrWhiteSpace() ? - await tenantDataRepository.GetListAsync>(idKey, whereQuery: p => p.DataType.Equals(dataType)) : - await tenantDataRepository.GetListAsync>(idKey, whereQuery: p => p.DataType.Equals(dataType) && - (p.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || p.DisplayName.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || - (p.Profiles != null && p.Profiles.Any(p => p.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || p.DisplayName.Contains(filterName, StringComparison.CurrentCultureIgnoreCase))) || - (doFilterPartyType && p.Type == filterPartyType))); - + (var mUpPartys, _) = await GetFilterUpPartyInternalAsync(filterName, filterHrdDomains); var aUpPartys = new HashSet(mUpPartys.Count()); - foreach(var mUpParty in mUpPartys.OrderBy(p => p.Type).ThenBy(p => p.Name)) + foreach (var mUpParty in mUpPartys.OrderBy(p => p.Type).ThenBy(p => p.Name)) { aUpPartys.Add(mapper.Map(mUpParty)); } @@ -67,5 +59,36 @@ await tenantDataRepository.GetListAsync>(idKe throw; } } + + private async Task<(IReadOnlyCollection> items, string paginationToken)> GetFilterUpPartyInternalAsync(string filterName, string filterHrdDomains) + { + var doFilterPartyType = Enum.TryParse(filterName, out var filterPartyType); + var idKey = new Track.IdKey { TenantName = RouteBinding.TenantName, TrackName = RouteBinding.TrackName }; + + if (filterName.IsNullOrWhiteSpace() && filterHrdDomains.IsNullOrWhiteSpace()) + { + return await tenantDataRepository.GetListAsync>(idKey, whereQuery: p => p.DataType.Equals(dataType)); + } + else if(!filterName.IsNullOrWhiteSpace() && filterHrdDomains.IsNullOrWhiteSpace()) + { + return await tenantDataRepository.GetListAsync>(idKey, whereQuery: p => p.DataType.Equals(dataType) && + (p.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || p.DisplayName.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || + (p.Profiles != null && p.Profiles.Any(p => p.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || p.DisplayName.Contains(filterName, StringComparison.CurrentCultureIgnoreCase))) || + (doFilterPartyType && p.Type == filterPartyType))); + } + else if (filterName.IsNullOrWhiteSpace() && !filterHrdDomains.IsNullOrWhiteSpace()) + { + return await tenantDataRepository.GetListAsync>(idKey, whereQuery: p => p.DataType.Equals(dataType) && + p.HrdDomains.Where(d => d.Contains(filterHrdDomains, StringComparison.CurrentCultureIgnoreCase)).Any()); + } + else + { + return await tenantDataRepository.GetListAsync>(idKey, whereQuery: p => p.DataType.Equals(dataType) && + (p.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || p.DisplayName.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || + (p.Profiles != null && p.Profiles.Any(p => p.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || p.DisplayName.Contains(filterName, StringComparison.CurrentCultureIgnoreCase))) || + (doFilterPartyType && p.Type == filterPartyType)) || + p.HrdDomains.Where(d => d.Contains(filterHrdDomains, StringComparison.CurrentCultureIgnoreCase)).Any()); + } + } } } diff --git a/src/FoxIDs.Control/Controllers/Parties/TLoginUpPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TLoginUpPartyController.cs index 4b876e627..c3073b205 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TLoginUpPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TLoginUpPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Parties/TOAuthDownPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TOAuthDownPartyController.cs index 348a5194a..fcdf0be18 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TOAuthDownPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TOAuthDownPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Parties/TOAuthUpPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TOAuthUpPartyController.cs index 8ec41ea5f..a4bdfa537 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TOAuthUpPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TOAuthUpPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Parties/TOidcDownPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TOidcDownPartyController.cs index 7daf1c540..470ee289c 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TOidcDownPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TOidcDownPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Parties/TOidcUpPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TOidcUpPartyController.cs index ecc20229e..364dca1ba 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TOidcUpPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TOidcUpPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Parties/TSamlDownPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TSamlDownPartyController.cs index 21b61aba5..6dc7821e2 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TSamlDownPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TSamlDownPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Parties/TSamlUpPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TSamlUpPartyController.cs index c2e8075c0..9bdc7c2f2 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TSamlUpPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TSamlUpPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Parties/TTrackLinkDownPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TTrackLinkDownPartyController.cs index 78daeb04d..b17835b8e 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TTrackLinkDownPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TTrackLinkDownPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Parties/TTrackLinkUpPartyController.cs b/src/FoxIDs.Control/Controllers/Parties/TTrackLinkUpPartyController.cs index f42d39db0..12faa919f 100644 --- a/src/FoxIDs.Control/Controllers/Parties/TTrackLinkUpPartyController.cs +++ b/src/FoxIDs.Control/Controllers/Parties/TTrackLinkUpPartyController.cs @@ -8,6 +8,7 @@ using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models.Config; +using FoxIDs.Logic.Queues; namespace FoxIDs.Controllers { diff --git a/src/FoxIDs.Control/Controllers/Tenants/TFilterTenantController.cs b/src/FoxIDs.Control/Controllers/Tenants/TFilterTenantController.cs index 5ac807c5a..3114866a0 100644 --- a/src/FoxIDs.Control/Controllers/Tenants/TFilterTenantController.cs +++ b/src/FoxIDs.Control/Controllers/Tenants/TFilterTenantController.cs @@ -33,7 +33,8 @@ public TFilterTenantController(TelemetryScopedLogger logger, IMapper mapper, ITe /// /// Filter tenant. /// - /// Filter tenant name. + /// Filter by tenant name. + /// Filter by custom domain. /// Tenants. [ProducesResponseType(typeof(HashSet), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -64,19 +65,19 @@ public TFilterTenantController(TelemetryScopedLogger logger, IMapper mapper, ITe { if (filterName.IsNullOrWhiteSpace() && filterCustomDomain.IsNullOrWhiteSpace()) { - return tenantDataRepository.GetListAsync(); + return tenantDataRepository.GetListAsync(whereQuery: t => !t.ForUsage && t.Name != Constants.Routes.MasterTenantName); } else if(!filterName.IsNullOrWhiteSpace() && filterCustomDomain.IsNullOrWhiteSpace()) { - return tenantDataRepository.GetListAsync(whereQuery: t => t.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase)); + return tenantDataRepository.GetListAsync(whereQuery: t => !t.ForUsage && t.Name != Constants.Routes.MasterTenantName && t.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase)); } else if (filterName.IsNullOrWhiteSpace() && !filterCustomDomain.IsNullOrWhiteSpace()) { - return tenantDataRepository.GetListAsync(whereQuery: t => t.CustomDomain.Contains(filterCustomDomain, StringComparison.CurrentCultureIgnoreCase)); + return tenantDataRepository.GetListAsync(whereQuery: t => !t.ForUsage && t.Name != Constants.Routes.MasterTenantName && t.CustomDomain.Contains(filterCustomDomain, StringComparison.CurrentCultureIgnoreCase)); } else { - return tenantDataRepository.GetListAsync(whereQuery: t => t.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || t.CustomDomain.Contains(filterCustomDomain, StringComparison.CurrentCultureIgnoreCase)); + return tenantDataRepository.GetListAsync(whereQuery: t => !t.ForUsage && t.Name != Constants.Routes.MasterTenantName && t.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase) || t.CustomDomain.Contains(filterCustomDomain, StringComparison.CurrentCultureIgnoreCase)); } } } diff --git a/src/FoxIDs.Control/Controllers/Tenants/TFilterUsageTenantController.cs b/src/FoxIDs.Control/Controllers/Tenants/TFilterUsageTenantController.cs new file mode 100644 index 000000000..af18d540f --- /dev/null +++ b/src/FoxIDs.Control/Controllers/Tenants/TFilterUsageTenantController.cs @@ -0,0 +1,66 @@ +using AutoMapper; +using FoxIDs.Infrastructure; +using FoxIDs.Repository; +using FoxIDs.Models; +using Api = FoxIDs.Models.Api; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using ITfoxtec.Identity; +using FoxIDs.Infrastructure.Security; +using FoxIDs.Infrastructure.Filters; +using System; + +namespace FoxIDs.Controllers +{ + [RequireMasterTenant] + [MasterScopeAuthorize] + public class TFilterUsageTenantController : ApiController + { + private readonly TelemetryScopedLogger logger; + private readonly IMapper mapper; + private readonly ITenantDataRepository tenantDataRepository; + + public TFilterUsageTenantController(TelemetryScopedLogger logger, IMapper mapper, ITenantDataRepository tenantDataRepository) : base(logger) + { + this.logger = logger; + this.mapper = mapper; + this.tenantDataRepository = tenantDataRepository; + } + + /// + /// Filter usage tenant. + /// + /// Filter usage tenant name. + /// Tenants. + [ProducesResponseType(typeof(HashSet), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetFilterUsageTenant(string filterName) + { + try + { + (var mTenants, _) = filterName.IsNullOrWhiteSpace() ? + await tenantDataRepository.GetListAsync(whereQuery: t => t.ForUsage && t.Name != Constants.Routes.MasterTenantName) : + await tenantDataRepository.GetListAsync(whereQuery: t => t.ForUsage && t.Name != Constants.Routes.MasterTenantName && t.Name.Contains(filterName, StringComparison.CurrentCultureIgnoreCase)); + + var aTenants = new HashSet(mTenants.Count()); + foreach (var mTenant in mTenants.OrderBy(t => t.Name)) + { + aTenants.Add(mapper.Map(mTenant)); + } + return Ok(aTenants); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.NotFound) + { + logger.Warning(ex, $"NotFound, Get '{typeof(Api.Tenant).Name}' by filter name '{filterName}'."); + return NotFound(typeof(Api.Tenant).Name, filterName); + } + throw; + } + } + } +} diff --git a/src/FoxIDs.Control/Controllers/Tenants/TMyMollieFirstPaymentController.cs b/src/FoxIDs.Control/Controllers/Tenants/TMyMollieFirstPaymentController.cs new file mode 100644 index 000000000..fc013015a --- /dev/null +++ b/src/FoxIDs.Control/Controllers/Tenants/TMyMollieFirstPaymentController.cs @@ -0,0 +1,107 @@ +using FoxIDs.Infrastructure; +using FoxIDs.Repository; +using FoxIDs.Models; +using Api = FoxIDs.Models.Api; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using System; +using FoxIDs.Infrastructure.Security; +using FoxIDs.Models.Config; +using Mollie.Api.Client.Abstract; +using Mollie.Api.Client; +using Mollie.Api.Models; +using Mollie.Api.Models.Payment; +using Mollie.Api.Models.Customer.Request; +using Mollie.Api.Models.Payment.Request.PaymentSpecificParameters; +using ITfoxtec.Identity; +using FoxIDs.Logic.Usage; + +namespace FoxIDs.Controllers +{ + /// + /// Register first Mollie payment. + /// + [TenantScopeAuthorize] + public class TMyMollieFirstPaymentController : ApiController + { + private readonly FoxIDsControlSettings settings; + private readonly TelemetryScopedLogger logger; + private readonly ITenantDataRepository tenantDataRepository; + private readonly ICustomerClient customerClient; + private readonly IPaymentClient paymentClient; + private readonly UsageMolliePaymentLogic usageMolliePaymentLogic; + + public TMyMollieFirstPaymentController(FoxIDsControlSettings settings, TelemetryScopedLogger logger, ITenantDataRepository tenantDataRepository, ICustomerClient customerClient, IPaymentClient paymentClient, UsageMolliePaymentLogic usageMolliePaymentLogic) : base(logger) + { + this.settings = settings; + this.logger = logger; + this.tenantDataRepository = tenantDataRepository; + this.customerClient = customerClient; + this.paymentClient = paymentClient; + this.usageMolliePaymentLogic = usageMolliePaymentLogic; + } + + /// + /// Register first Mollie payment. + /// + /// First payment. + /// Tenant. + [ProducesResponseType(typeof(Api.MollieFirstPaymentResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> PostMyMollieFirstPayment([FromBody] Api.MollieFirstPaymentRequest payment) + { + if(settings.Payment?.EnablePayment != true || settings.Usage?.EnableInvoice != true) + { + throw new Exception("Payment not configured."); + } + + var mTenant = await tenantDataRepository.GetTenantByNameAsync(RouteBinding.TenantName); + if(string.IsNullOrWhiteSpace(mTenant.Payment?.CustomerId)) + { + try + { + var customerResponse = await customerClient.CreateCustomerAsync(new CustomerRequest() + { + Name = RouteBinding.TenantName + }); + + mTenant.Payment = new Payment + { + CustomerId = customerResponse.Id + }; + await tenantDataRepository.UpdateAsync(mTenant); + } + catch (MollieApiException ex) + { + logger.Error(ex, "Create Mollie customer error."); + throw new Exception("Unable to create customer in Mollie."); + } + } + + var paymentRequest = new CreditCardPaymentRequest + { + Amount = new Amount("EUR", "0.00"), + RedirectUrl = $"{HttpContext.GetHost()}{RouteBinding.TenantName}/tenantpaymentresponse", + Description = "Zero amount registration payment", + CustomerId = mTenant.Payment.CustomerId, + SequenceType = SequenceType.First, + CardToken = payment.CardToken + }; + + var paymentResponse = await paymentClient.CreatePaymentAsync(paymentRequest); + + mTenant.Payment.IsActive = false; + if (!mTenant.Payment.MandateId.IsNullOrWhiteSpace()) + { + await usageMolliePaymentLogic.RevokePaymentMandateAsync(mTenant); + } + mTenant.Payment.MandateId = paymentResponse.MandateId; + await tenantDataRepository.UpdateAsync(mTenant); + + await usageMolliePaymentLogic.UpdatePaymentMandate(mTenant); + + return Ok(new Api.MollieFirstPaymentResponse { CheckoutUrl = paymentResponse.Links?.Checkout?.Href }); + } + } +} diff --git a/src/FoxIDs.Control/Controllers/Tenants/TMyTenantController.cs b/src/FoxIDs.Control/Controllers/Tenants/TMyTenantController.cs index 2d870e887..ed9b09746 100644 --- a/src/FoxIDs.Control/Controllers/Tenants/TMyTenantController.cs +++ b/src/FoxIDs.Control/Controllers/Tenants/TMyTenantController.cs @@ -12,27 +12,31 @@ using FoxIDs.Infrastructure.Security; using Microsoft.Extensions.DependencyInjection; using FoxIDs.Models.Config; +using System.Linq; +using FoxIDs.Logic.Usage; namespace FoxIDs.Controllers { [TenantScopeAuthorize] public class TMyTenantController : ApiController { - private readonly Settings settings; + private readonly FoxIDsControlSettings settings; private readonly TelemetryScopedLogger logger; private readonly IServiceProvider serviceProvider; private readonly IMapper mapper; + private readonly IMasterDataRepository masterDataRepository; private readonly ITenantDataRepository tenantDataRepository; private readonly PlanCacheLogic planCacheLogic; private readonly TenantCacheLogic tenantCacheLogic; private readonly TrackCacheLogic trackCacheLogic; - public TMyTenantController(Settings settings, TelemetryScopedLogger logger, IServiceProvider serviceProvider, IMapper mapper, ITenantDataRepository tenantDataRepository, PlanCacheLogic planCacheLogic, TenantCacheLogic tenantCacheLogic, TrackCacheLogic trackCacheLogic) : base(logger) + public TMyTenantController(FoxIDsControlSettings settings, TelemetryScopedLogger logger, IServiceProvider serviceProvider, IMapper mapper, IMasterDataRepository masterDataRepository, ITenantDataRepository tenantDataRepository, PlanCacheLogic planCacheLogic, TenantCacheLogic tenantCacheLogic, TrackCacheLogic trackCacheLogic) : base(logger) { this.settings = settings; this.logger = logger; this.serviceProvider = serviceProvider; this.mapper = mapper; + this.masterDataRepository = masterDataRepository; this.tenantDataRepository = tenantDataRepository; this.planCacheLogic = planCacheLogic; this.tenantCacheLogic = tenantCacheLogic; @@ -43,14 +47,17 @@ public TMyTenantController(Settings settings, TelemetryScopedLogger logger, ISer /// Get my tenant. /// /// Tenant. - [ProducesResponseType(typeof(Api.Tenant), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Api.TenantResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetMyTenant() + public async Task> GetMyTenant() { try { - var MTenant = await tenantDataRepository.GetTenantByNameAsync(RouteBinding.TenantName); - return Ok(mapper.Map(MTenant)); + var mTenant = await tenantDataRepository.GetTenantByNameAsync(RouteBinding.TenantName); + + await UpdatePaymentMandate(mTenant); + + return Ok(mapper.Map(mTenant)); } catch (FoxIDsDataException ex) { @@ -68,9 +75,9 @@ public TMyTenantController(Settings settings, TelemetryScopedLogger logger, ISer /// /// Tenant. /// Tenant. - [ProducesResponseType(typeof(Api.Tenant), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Api.TenantResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> PutMyTenant([FromBody] Api.MyTenantRequest tenant) + public async Task> PutMyTenant([FromBody] Api.MyTenantRequest tenant) { try { @@ -87,8 +94,31 @@ public TMyTenantController(Settings settings, TelemetryScopedLogger logger, ISer } } + try + { + if (settings.Payment.EnablePayment == true && settings.Usage?.EnableInvoice == true && !tenant.PlanName.IsNullOrEmpty()) + { + tenant.PlanName = tenant.PlanName.ToLower(); + if (tenant.PlanName != RouteBinding.PlanName) + { + var mPlans = await masterDataRepository.GetListAsync(); + decimal currentCost = RouteBinding.PlanName.IsNullOrEmpty() ? 0 : mPlans.Where(p => p.Name == RouteBinding.PlanName).Select(p => p.CostPerMonth).FirstOrDefault(); + decimal updateCost = mPlans.Where(p => p.Name == tenant.PlanName).Select(p => p.CostPerMonth).FirstOrDefault(); + if (updateCost >= currentCost) + { + mTenant.PlanName = tenant.PlanName; + } + } + } + } + catch (Exception exp) + { + logger.Error(exp, "Unable to update plan in tenant."); + } + mTenant.CustomDomain = tenant.CustomDomain; mTenant.CustomDomainVerified = false; + mTenant.Customer = mapper.Map(tenant.Customer); await tenantDataRepository.UpdateAsync(mTenant); await tenantCacheLogic.InvalidateTenantCacheAsync(RouteBinding.TenantName); @@ -97,7 +127,9 @@ public TMyTenantController(Settings settings, TelemetryScopedLogger logger, ISer await tenantCacheLogic.InvalidateCustomDomainCacheAsync(invalidateCustomDomainInCache); } - return Ok(mapper.Map(mTenant)); + await UpdatePaymentMandate(mTenant); + + return Ok(mapper.Map(mTenant)); } catch (FoxIDsDataException ex) { @@ -110,6 +142,15 @@ public TMyTenantController(Settings settings, TelemetryScopedLogger logger, ISer } } + private async Task UpdatePaymentMandate(Tenant mTenant) + { + if (settings.Payment?.EnablePayment == true && settings.Usage?.EnableInvoice == true) + { + var usageMolliePaymentLogic = serviceProvider.GetService(); + await usageMolliePaymentLogic.UpdatePaymentMandate(mTenant); + } + } + /// /// Delete my tenant. /// diff --git a/src/FoxIDs.Control/Controllers/Tenants/TMyTenantLogUsageController.cs b/src/FoxIDs.Control/Controllers/Tenants/TMyTenantLogUsageController.cs index 9f8ef1b00..fe8cce372 100644 --- a/src/FoxIDs.Control/Controllers/Tenants/TMyTenantLogUsageController.cs +++ b/src/FoxIDs.Control/Controllers/Tenants/TMyTenantLogUsageController.cs @@ -39,7 +39,7 @@ public TMyTenantLogUsageController(TelemetryScopedLogger logger, UsageLogLogic u logRequest.TrackName = null; } - var logResponse = await usageLogLogic.GetTrackUsageLog(logRequest, RouteBinding.TenantName, logRequest.TrackName, isMasterTrack: true); + var logResponse = await usageLogLogic.GetTrackUsageLogAsync(logRequest, RouteBinding.TenantName, logRequest.TrackName, isMasterTrack: true); return Ok(logResponse); } } diff --git a/src/FoxIDs.Control/Controllers/Tenants/TTenantController.cs b/src/FoxIDs.Control/Controllers/Tenants/TTenantController.cs index ddb997a2c..0a144333f 100644 --- a/src/FoxIDs.Control/Controllers/Tenants/TTenantController.cs +++ b/src/FoxIDs.Control/Controllers/Tenants/TTenantController.cs @@ -13,6 +13,7 @@ using ITfoxtec.Identity; using FoxIDs.Models.Config; using Microsoft.Extensions.DependencyInjection; +using FoxIDs.Logic.Usage; namespace FoxIDs.Controllers { @@ -48,17 +49,21 @@ public TTenantController(FoxIDsControlSettings settings, TelemetryScopedLogger l /// /// Tenant name. /// Tenant. - [ProducesResponseType(typeof(Api.Tenant), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Api.TenantResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetTenant(string name) + public async Task> GetTenant(string name) { try { if (!ModelState.TryValidateRequiredParameter(name, nameof(name))) return BadRequest(ModelState); name = name?.ToLower(); - var MTenant = await tenantDataRepository.GetTenantByNameAsync(name); - return Ok(mapper.Map(MTenant)); + var mTenant = await tenantDataRepository.GetTenantByNameAsync(name); + if (!mTenant.ForUsage) + { + await UpdatePaymentMandate(mTenant); + } + return Ok(mapper.Map(mTenant)); } catch (FoxIDsDataException ex) { @@ -76,50 +81,63 @@ public TTenantController(FoxIDsControlSettings settings, TelemetryScopedLogger l /// /// Tenant. /// Tenant. - [ProducesResponseType(typeof(Api.Tenant), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(Api.TenantResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task> PostTenant([FromBody] Api.CreateTenantRequest tenant) + public async Task> PostTenant([FromBody] Api.CreateTenantRequest tenant) { try { if (!await ModelState.TryValidateObjectAsync(tenant)) return BadRequest(ModelState); tenant.Name = tenant.Name.ToLower(); - tenant.AdministratorEmail = tenant.AdministratorEmail?.ToLower(); - if (tenant.Name == Constants.Routes.ControlSiteName || tenant.Name == Constants.Routes.HealthController) + if (!tenant.ForUsage) { - throw new FoxIDsDataException($"A tenant can not have the name '{tenant.Name}'.") { StatusCode = DataStatusCode.Conflict }; + tenant.AdministratorEmail = tenant.AdministratorEmail?.ToLower(); + + if (tenant.Name == Constants.Routes.ControlSiteName || tenant.Name == Constants.Routes.HealthController) + { + throw new FoxIDsDataException($"A tenant can not have the name '{tenant.Name}'.") { StatusCode = DataStatusCode.Conflict }; + } } - (var validPlan, var plan) = await ValidatePlanAsync(tenant.Name, nameof(tenant.PlanName), tenant.PlanName); - if (!validPlan) return BadRequest(ModelState); + var mTenant = mapper.Map(tenant); + mTenant.Customer = mapper.Map(tenant.Customer); + mTenant.CreateTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - if (plan != null && !tenant.CustomDomain.IsNullOrEmpty()) + if (!tenant.ForUsage) { - if (!plan.EnableCustomDomain) + (var validPlan, var plan) = await ValidatePlanAsync(tenant.Name, nameof(tenant.PlanName), tenant.PlanName); + if (!validPlan) return BadRequest(ModelState); + + if (plan != null && !tenant.CustomDomain.IsNullOrEmpty()) { - throw new Exception($"Custom domain is not supported in the '{plan.Name}' plan."); + if (!plan.EnableCustomDomain) + { + throw new Exception($"Custom domain is not supported in the '{plan.Name}' plan."); + } } + mTenant.PlanName = plan?.Name; } - var mTenant = mapper.Map(tenant); await tenantDataRepository.CreateAsync(mTenant); - await tenantCacheLogic.InvalidateTenantCacheAsync(tenant.Name); - if (!string.IsNullOrEmpty(tenant.CustomDomain)) + if (!tenant.ForUsage) { - await tenantCacheLogic.InvalidateCustomDomainCacheAsync(tenant.CustomDomain); - } - - await masterTenantLogic.CreateMasterTrackDocumentAsync(tenant.Name); - var mLoginUpParty = await masterTenantLogic.CreateMasterLoginDocumentAsync(tenant.Name); - await masterTenantLogic.CreateFirstAdminUserDocumentAsync(tenant.Name, tenant.AdministratorEmail, tenant.AdministratorPassword, tenant.ChangeAdministratorPassword, true, tenant.ConfirmAdministratorAccount); - await masterTenantLogic.CreateMasterFoxIDsControlApiResourceDocumentAsync(tenant.Name); - await masterTenantLogic.CreateMasterControlClientDocmentAsync(tenant.Name, tenant.ControlClientBaseUri, mLoginUpParty); + await tenantCacheLogic.InvalidateTenantCacheAsync(tenant.Name); + if (!string.IsNullOrEmpty(tenant.CustomDomain)) + { + await tenantCacheLogic.InvalidateCustomDomainCacheAsync(tenant.CustomDomain); + } - await masterTenantLogic.CreateDefaultTracksDocmentsAsync(tenant.Name); + await masterTenantLogic.CreateMasterTrackDocumentAsync(tenant.Name); + var mLoginUpParty = await masterTenantLogic.CreateMasterLoginDocumentAsync(tenant.Name); + await masterTenantLogic.CreateFirstAdminUserDocumentAsync(tenant.Name, tenant.AdministratorEmail, tenant.AdministratorPassword, tenant.ChangeAdministratorPassword, true, tenant.ConfirmAdministratorAccount); + await masterTenantLogic.CreateMasterFoxIDsControlApiResourceDocumentAsync(tenant.Name); + await masterTenantLogic.CreateMasterControlClientDocmentAsync(tenant.Name, tenant.ControlClientBaseUri, mLoginUpParty); - return Created(mapper.Map(mTenant)); + await masterTenantLogic.CreateDefaultTracksDocmentsAsync(tenant.Name); + } + return Created(mapper.Map(mTenant)); } catch (AccountException aex) { @@ -161,9 +179,9 @@ public TTenantController(FoxIDsControlSettings settings, TelemetryScopedLogger l /// /// Tenant. /// Tenant. - [ProducesResponseType(typeof(Api.Tenant), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(Api.TenantResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> PutTenant([FromBody] Api.TenantRequest tenant) + public async Task> PutTenant([FromBody] Api.TenantRequest tenant) { try { @@ -171,32 +189,45 @@ public TTenantController(FoxIDsControlSettings settings, TelemetryScopedLogger l tenant.Name = tenant.Name.ToLower(); var mTenant = await tenantDataRepository.GetTenantByNameAsync(tenant.Name); - var invalidateCustomDomainInCache = (!mTenant.CustomDomain.IsNullOrEmpty() && !mTenant.CustomDomain.Equals(tenant.CustomDomain, StringComparison.OrdinalIgnoreCase)) ? mTenant.CustomDomain : null; - (var validPlan, var plan) = await ValidatePlanAsync(tenant.Name, nameof(tenant.PlanName), tenant.PlanName); - if (!validPlan) return BadRequest(ModelState); - - mTenant.PlanName = tenant.PlanName; - - if (plan != null && !tenant.CustomDomain.IsNullOrEmpty()) + if (!tenant.ForUsage) { - if (!plan.EnableCustomDomain) + (var validPlan, var plan) = await ValidatePlanAsync(tenant.Name, nameof(tenant.PlanName), tenant.PlanName); + if (!validPlan) return BadRequest(ModelState); + + mTenant.PlanName = plan?.Name; + + if (plan != null && !tenant.CustomDomain.IsNullOrEmpty()) { - throw new Exception($"Custom domain is not supported in the '{plan.Name}' plan."); + if (!plan.EnableCustomDomain) + { + throw new Exception($"Custom domain is not supported in the '{plan.Name}' plan."); + } } + mTenant.CustomDomain = tenant.CustomDomain; + mTenant.CustomDomainVerified = tenant.CustomDomainVerified; } - mTenant.CustomDomain = tenant.CustomDomain; - mTenant.CustomDomainVerified = tenant.CustomDomainVerified; + mTenant.EnableUsage = tenant.EnableUsage; + mTenant.DoPayment = tenant.DoPayment; + mTenant.Currency = tenant.Currency; + mTenant.IncludeVat = tenant.IncludeVat; + mTenant.HourPrice = tenant.HourPrice; + mTenant.Customer = mapper.Map(tenant.Customer); await tenantDataRepository.UpdateAsync(mTenant); await tenantCacheLogic.InvalidateTenantCacheAsync(tenant.Name); - if (!invalidateCustomDomainInCache.IsNullOrEmpty()) + if (!tenant.ForUsage) { - await tenantCacheLogic.InvalidateCustomDomainCacheAsync(invalidateCustomDomainInCache); + if (!invalidateCustomDomainInCache.IsNullOrEmpty()) + { + await tenantCacheLogic.InvalidateCustomDomainCacheAsync(invalidateCustomDomainInCache); + } + + await UpdatePaymentMandate(mTenant); } - return Ok(mapper.Map(mTenant)); + return Ok(mapper.Map(mTenant)); } catch (FoxIDsDataException ex) { @@ -237,6 +268,15 @@ public TTenantController(FoxIDsControlSettings settings, TelemetryScopedLogger l } } + private async Task UpdatePaymentMandate(Tenant mTenant) + { + if (settings.Payment?.EnablePayment == true && settings.Usage?.EnableInvoice == true) + { + var usageMolliePaymentLogic = serviceProvider.GetService(); + await usageMolliePaymentLogic.UpdatePaymentMandate(mTenant); + } + } + /// /// Delete tenant. /// diff --git a/src/FoxIDs.Control/Controllers/Tenants/TTenantLogUsageController.cs b/src/FoxIDs.Control/Controllers/Tenants/TTenantLogUsageController.cs index c8793ce96..b6c8d8f9b 100644 --- a/src/FoxIDs.Control/Controllers/Tenants/TTenantLogUsageController.cs +++ b/src/FoxIDs.Control/Controllers/Tenants/TTenantLogUsageController.cs @@ -50,7 +50,7 @@ public TTenantLogUsageController(TelemetryScopedLogger logger, UsageLogLogic usa logRequest.TrackName = null; } - var logResponse = await usageLogLogic.GetTrackUsageLog(logRequest, logRequest.TenantName, logRequest.TrackName, isMasterTenant: true); + var logResponse = await usageLogLogic.GetTrackUsageLogAsync(logRequest, logRequest.TenantName, logRequest.TrackName, isMasterTenant: true); return Ok(logResponse); } } diff --git a/src/FoxIDs.Control/Controllers/Tracks/TExternalUserController.cs b/src/FoxIDs.Control/Controllers/Tracks/TExternalUserController.cs index 72508ebde..086d6f91a 100644 --- a/src/FoxIDs.Control/Controllers/Tracks/TExternalUserController.cs +++ b/src/FoxIDs.Control/Controllers/Tracks/TExternalUserController.cs @@ -6,8 +6,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; -using System.Net; -using ITfoxtec.Identity; using FoxIDs.Infrastructure.Security; using System; using FoxIDs.Logic; diff --git a/src/FoxIDs.Control/Controllers/Tracks/TFilterTrackController.cs b/src/FoxIDs.Control/Controllers/Tracks/TFilterTrackController.cs index fc8fd1bd6..a36814f6e 100644 --- a/src/FoxIDs.Control/Controllers/Tracks/TFilterTrackController.cs +++ b/src/FoxIDs.Control/Controllers/Tracks/TFilterTrackController.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System.Threading.Tasks; -using System.Net; using System.Collections.Generic; using System.Linq; using ITfoxtec.Identity; diff --git a/src/FoxIDs.Control/Controllers/Tracks/TTrackController.cs b/src/FoxIDs.Control/Controllers/Tracks/TTrackController.cs index 6e96db0b7..41714d783 100644 --- a/src/FoxIDs.Control/Controllers/Tracks/TTrackController.cs +++ b/src/FoxIDs.Control/Controllers/Tracks/TTrackController.cs @@ -90,13 +90,13 @@ public TTrackController(FoxIDsControlSettings settings, TelemetryScopedLogger lo if (!RouteBinding.PlanName.IsNullOrEmpty()) { var plan = await planCacheLogic.GetPlanAsync(RouteBinding.PlanName); - if (plan.Tracks.IsLimited) + if (plan.Tracks.LimitedThreshold > 0) { var count = await tenantDataRepository.CountAsync(new Track.IdKey { TenantName = RouteBinding.TenantName }); // included + master track - if (count > plan.Tracks.Included) + if (count > plan.Tracks.LimitedThreshold) { - throw new Exception($"Maximum number of tracks ({plan.Tracks.Included}) included in the '{plan.Name}' plan has been reached. Master environment not counted."); + throw new Exception($"Maximum number of tracks ({plan.Tracks.LimitedThreshold}) in the '{plan.Name}' plan has been reached. Master environment not counted."); } } } diff --git a/src/FoxIDs.Control/Controllers/Tracks/TTrackLogController.cs b/src/FoxIDs.Control/Controllers/Tracks/TTrackLogController.cs index a3c9e67b4..dda91a36a 100644 --- a/src/FoxIDs.Control/Controllers/Tracks/TTrackLogController.cs +++ b/src/FoxIDs.Control/Controllers/Tracks/TTrackLogController.cs @@ -70,7 +70,7 @@ public TTrackLogController(FoxIDsControlSettings settings, TelemetryScopedLogger case LogOptions.OpenSearchAndStdoutErrors: return Ok(await serviceProvider.GetService().QueryLogs(logRequest, (start.DateTime, end.DateTime), maxResponseLogItems)); case LogOptions.ApplicationInsights: - return Ok(await serviceProvider.GetService().QueryLogs(logRequest, new QueryTimeRange(start, end), maxResponseLogItems)); + return Ok(await serviceProvider.GetService().QueryLogsAsync(logRequest, new QueryTimeRange(start, end), maxResponseLogItems)); default: throw new NotSupportedException(); } diff --git a/src/FoxIDs.Control/Controllers/Tracks/TTrackLogUsageController.cs b/src/FoxIDs.Control/Controllers/Tracks/TTrackLogUsageController.cs index 825fe9d60..767816516 100644 --- a/src/FoxIDs.Control/Controllers/Tracks/TTrackLogUsageController.cs +++ b/src/FoxIDs.Control/Controllers/Tracks/TTrackLogUsageController.cs @@ -38,7 +38,7 @@ public TTrackLogUsageController(FoxIDsControlSettings settings, TelemetryScopedL if (!await ModelState.TryValidateObjectAsync(logRequest)) return BadRequest(ModelState); - var logResponse = await usageLogLogic.GetTrackUsageLog(logRequest, RouteBinding.TenantName, RouteBinding.TrackName); + var logResponse = await usageLogLogic.GetTrackUsageLogAsync(logRequest, RouteBinding.TenantName, RouteBinding.TrackName); return Ok(logResponse); } } diff --git a/src/FoxIDs.Control/Controllers/Tracks/TUserController.cs b/src/FoxIDs.Control/Controllers/Tracks/TUserController.cs index c06b990e7..ff239fbb1 100644 --- a/src/FoxIDs.Control/Controllers/Tracks/TUserController.cs +++ b/src/FoxIDs.Control/Controllers/Tracks/TUserController.cs @@ -81,14 +81,14 @@ public TUserController(TelemetryScopedLogger logger, IServiceProvider servicePro if (!RouteBinding.PlanName.IsNullOrEmpty()) { var plan = await planCacheLogic.GetPlanAsync(RouteBinding.PlanName); - if (plan.Users.IsLimited) + if (plan.Users.LimitedThreshold > 0) { Expression> whereQuery = p => p.DataType.Equals("user") && p.PartitionId.StartsWith($"{RouteBinding.TenantName}:"); var count = await tenantDataRepository.CountAsync(whereQuery: whereQuery, usePartitionId: false); // included + one master user - if (count > plan.Users.Included) + if (count > plan.Users.LimitedThreshold) { - throw new Exception($"Maximum number of users ({plan.Users.Included}) included in the '{plan.Name}' plan has been reached."); + throw new Exception($"Maximum number of users ({plan.Users.LimitedThreshold}) in the '{plan.Name}' plan has been reached."); } } } diff --git a/src/FoxIDs.Control/Controllers/Usage/TFilterUsageController.cs b/src/FoxIDs.Control/Controllers/Usage/TFilterUsageController.cs new file mode 100644 index 000000000..e34300e30 --- /dev/null +++ b/src/FoxIDs.Control/Controllers/Usage/TFilterUsageController.cs @@ -0,0 +1,92 @@ +using AutoMapper; +using FoxIDs.Infrastructure; +using FoxIDs.Repository; +using Api = FoxIDs.Models.Api; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using ITfoxtec.Identity; +using FoxIDs.Infrastructure.Security; +using FoxIDs.Infrastructure.Filters; +using System; +using FoxIDs.Models; +using FoxIDs.Logic.Usage; + +namespace FoxIDs.Controllers +{ + [RequireMasterTenant] + [MasterScopeAuthorize] + public class TFilterUsageController : ApiController + { + private readonly TelemetryScopedLogger logger; + private readonly IMapper mapper; + private readonly ITenantDataRepository tenantDataRepository; + private readonly UsageMolliePaymentLogic usageMolliePaymentLogic; + + public TFilterUsageController(TelemetryScopedLogger logger, IMapper mapper, ITenantDataRepository tenantDataRepository, UsageMolliePaymentLogic usageMolliePaymentLogic) : base(logger) + { + this.logger = logger; + this.mapper = mapper; + this.tenantDataRepository = tenantDataRepository; + this.usageMolliePaymentLogic = usageMolliePaymentLogic; + } + + /// + /// Filter Usage. + /// + /// Filter by tenant name. + /// The year. + /// The month. + /// Tenants. + [ProducesResponseType(typeof(HashSet), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetFilterUsage(string filterTenantName, int year, int month) + { + try + { + if (year <= 0 || month <= 0) + { + var now = DateTimeOffset.Now; + year = now.Year; + month = now.Month; + } + + (var mUsedList, _) = filterTenantName.IsNullOrWhiteSpace() ? + await tenantDataRepository.GetListAsync(whereQuery: u => u.PeriodEndDate.Month == month && u.PeriodEndDate.Year == year) : + await tenantDataRepository.GetListAsync(whereQuery: u => u.PeriodEndDate.Month == month && u.PeriodEndDate.Year == year && u.TenantName.Contains(filterTenantName, StringComparison.CurrentCultureIgnoreCase)); + + var aUsedList = new HashSet(mUsedList.Count()); + foreach (var mUsed in mUsedList.OrderBy(t => t.TenantName)) + { + if(mUsed.PaymentStatus == UsagePaymentStatus.Open || mUsed.PaymentStatus == UsagePaymentStatus.Pending || mUsed.PaymentStatus == UsagePaymentStatus.Authorized) + { + await usageMolliePaymentLogic.UpdatePaymentAsync(mUsed); + } + var aUsed = mapper.Map(mUsed); + var mLastInvoice = mUsed.Invoices?.LastOrDefault(); + if(mLastInvoice != null) + { + var aLastInvoice = mapper.Map(mLastInvoice); + aLastInvoice.Lines = null; + aLastInvoice.TimeItems = null; + aUsed.Invoices = [aLastInvoice]; + + } + aUsedList.Add(aUsed); + } + return Ok(aUsedList); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.NotFound) + { + logger.Warning(ex, $"NotFound, Get '{typeof(Api.Used).Name}' by filter tenant name '{filterTenantName}'."); + return NotFound(typeof(Api.Used).Name, filterTenantName); + } + throw; + } + } + } +} diff --git a/src/FoxIDs.Control/Controllers/Usage/TUsageController.cs b/src/FoxIDs.Control/Controllers/Usage/TUsageController.cs new file mode 100644 index 000000000..93219e301 --- /dev/null +++ b/src/FoxIDs.Control/Controllers/Usage/TUsageController.cs @@ -0,0 +1,194 @@ +using AutoMapper; +using FoxIDs.Infrastructure; +using FoxIDs.Repository; +using FoxIDs.Models; +using Api = FoxIDs.Models.Api; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using FoxIDs.Infrastructure.Security; +using FoxIDs.Infrastructure.Filters; +using System; +using System.Collections.Generic; +using System.Linq; +using ITfoxtec.Identity; + +namespace FoxIDs.Controllers +{ + [RequireMasterTenant] + [MasterScopeAuthorize] + public class TUsageController : ApiController + { + private readonly TelemetryScopedLogger logger; + private readonly IMapper mapper; + private readonly ITenantDataRepository tenantDataRepository; + + public object MTenant { get; private set; } + + public TUsageController(TelemetryScopedLogger logger, IMapper mapper, ITenantDataRepository tenantDataRepository) : base(logger) + { + this.logger = logger; + this.mapper = mapper; + this.tenantDataRepository = tenantDataRepository; + } + + /// + /// Get usage. + /// + /// Usage request. + /// Used. + [ProducesResponseType(typeof(Api.Used), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetUsage(Api.UsageRequest usageRequest) + { + try + { + if (!await ModelState.TryValidateObjectAsync(usageRequest)) return BadRequest(ModelState); + usageRequest.TenantName = usageRequest.TenantName?.ToLower(); + + var mTenant = await tenantDataRepository.GetAsync(await Tenant.IdFormatAsync(usageRequest.TenantName)); + + var mUsed = await tenantDataRepository.GetAsync(await Used.IdFormatAsync(usageRequest.TenantName, usageRequest.PeriodBeginDate.Year, usageRequest.PeriodBeginDate.Month)); + + var aUsed = mapper.Map(mUsed); + aUsed.Currency = GetCulture(mTenant); + return Ok(aUsed); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.NotFound) + { + logger.Warning(ex, $"NotFound, Get '{typeof(Api.Used).Name}' by tenant name '{usageRequest.TenantName}', year '{usageRequest.PeriodBeginDate.Year}' and month '{usageRequest.PeriodBeginDate.Month}'."); + return NotFound(typeof(Api.Used).Name, $"{usageRequest.TenantName}/{usageRequest.PeriodBeginDate.Year}/{usageRequest.PeriodBeginDate.Month}"); + } + throw; + } + } + + /// + /// Create usage. + /// + /// Usage request. + /// Used. + [ProducesResponseType(typeof(Api.Used), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> PostUsage([FromBody] Api.UpdateUsageRequest usageRequest) + { + try + { + if (!await ModelState.TryValidateObjectAsync(usageRequest)) return BadRequest(ModelState); + usageRequest.TenantName = usageRequest.TenantName.ToLower(); + + var mTenant = await tenantDataRepository.GetAsync(await Tenant.IdFormatAsync(usageRequest.TenantName)); + + var mUsed = mapper.Map(usageRequest); + if(!usageRequest.PeriodEndDate.HasValue) + { + mUsed.PeriodEndDate = mUsed.PeriodBeginDate.AddMonths(1).AddDays(-1); + } + mUsed.Items = mUsed.Items?.OrderBy(i => i.Type).ThenBy(i => i.Day).ToList(); + await tenantDataRepository.CreateAsync(mUsed); + + var aUsed = mapper.Map(mUsed); + aUsed.Currency = GetCulture(mTenant); + return Created(aUsed); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.Conflict) + { + logger.Warning(ex, $"Conflict, Create '{typeof(Api.Used).Name}' by tenant name '{usageRequest.TenantName}', year '{usageRequest.PeriodBeginDate.Year}' and month '{usageRequest.PeriodBeginDate.Month}'."); + return Conflict(typeof(Api.Used).Name, $"{usageRequest.TenantName}/{usageRequest.PeriodBeginDate.Year}/{usageRequest.PeriodBeginDate.Month}"); + } + throw; + } + } + + /// + /// Update usage. + /// + /// Usage request. + /// Used. + [ProducesResponseType(typeof(Api.Used), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PutUsage([FromBody] Api.UpdateUsageRequest usageRequest) + { + try + { + if (!await ModelState.TryValidateObjectAsync(usageRequest)) return BadRequest(ModelState); + usageRequest.TenantName = usageRequest.TenantName.ToLower(); + + var mTenant = await tenantDataRepository.GetAsync(await Tenant.IdFormatAsync(usageRequest.TenantName)); + + var mUsed = await tenantDataRepository.GetAsync(await Used.IdFormatAsync(usageRequest.TenantName, usageRequest.PeriodBeginDate.Year, usageRequest.PeriodBeginDate.Month)); + if (usageRequest.Items?.Count() > 0) + { + mUsed.Items = mapper.Map>(usageRequest.Items).OrderBy(i => i.Type).ThenBy(i => i.Day).ToList(); + } + else + { + mUsed.Items = null; + } + await tenantDataRepository.UpdateAsync(mUsed); + + var aUsed = mapper.Map(mUsed); + aUsed.Currency = GetCulture(mTenant); + return Ok(aUsed); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.NotFound) + { + logger.Warning(ex, $"NotFound, Update '{typeof(Api.Used).Name}' by tenant name '{usageRequest.TenantName}', year '{usageRequest.PeriodBeginDate.Year}' and month '{usageRequest.PeriodBeginDate.Month}'."); + return NotFound(typeof(Api.Used).Name, $"{usageRequest.TenantName}/{usageRequest.PeriodBeginDate.Year}/{usageRequest.PeriodBeginDate.Month}"); + } + throw; + } + } + + private string GetCulture(Tenant mTenant) + { + return mTenant.Currency.IsNullOrEmpty() ? Constants.Models.Currency.Eur : mTenant.Currency; + } + + /// + /// Delete usage. + /// + /// Usage name. + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteUsage(string name) + { + try + { + if (!ModelState.TryValidateRequiredParameter(name, nameof(name))) return BadRequest(ModelState); + name = name?.ToLower(); + + var nameSplit = name.Split('/'); + var year = 0; + var month = 0; + if (nameSplit.Length != 3 || !int.TryParse(nameSplit[1], out year) || !int.TryParse(nameSplit[2], out month)) + { + throw new ArgumentException($"Invalid name '{name}' format.", nameof(name)); + } + var mUsed = await tenantDataRepository.GetAsync(await Used.IdFormatAsync(nameSplit[0], year, month)); + if (mUsed.IsUsageCalculated || mUsed.Invoices?.Count() > 0) + { + throw new Exception($"Used item '{name}' cannot be deleted"); + } + + await tenantDataRepository.DeleteAsync(await Used.IdFormatAsync(nameSplit[0], year, month)); + return NoContent(); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.NotFound) + { + logger.Warning(ex, $"NotFound, Delete '{typeof(Api.Used).Name}' by name '{name}'."); + return NotFound(typeof(Api.Used).Name, name); + } + throw; + } + } + } +} diff --git a/src/FoxIDs.Control/Controllers/Usage/TUsageInvoicingActionController.cs b/src/FoxIDs.Control/Controllers/Usage/TUsageInvoicingActionController.cs new file mode 100644 index 000000000..ed666ed91 --- /dev/null +++ b/src/FoxIDs.Control/Controllers/Usage/TUsageInvoicingActionController.cs @@ -0,0 +1,202 @@ +using AutoMapper; +using FoxIDs.Infrastructure; +using FoxIDs.Repository; +using FoxIDs.Models; +using Api = FoxIDs.Models.Api; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using FoxIDs.Infrastructure.Security; +using FoxIDs.Infrastructure.Filters; +using System; +using FoxIDs.Models.Config; +using FoxIDs.Logic.Usage; +using System.Linq; +using System.Threading; + +namespace FoxIDs.Controllers +{ + [RequireMasterTenant] + [MasterScopeAuthorize] + public class TUsageInvoicingActionController : ApiController + { + private readonly FoxIDsControlSettings settings; + private readonly TelemetryScopedLogger logger; + private readonly IMapper mapper; + private readonly ITenantDataRepository tenantDataRepository; + private readonly UsageInvoicingLogic usageInvoicingLogic; + private readonly UsageMolliePaymentLogic usageMolliePaymentLogic; + + public object MTenant { get; private set; } + + public TUsageInvoicingActionController(FoxIDsControlSettings settings, TelemetryScopedLogger logger, IMapper mapper, ITenantDataRepository tenantDataRepository, UsageInvoicingLogic usageInvoicingLogic, UsageMolliePaymentLogic usageMolliePaymentLogic) : base(logger) + { + this.settings = settings; + this.logger = logger; + this.mapper = mapper; + this.tenantDataRepository = tenantDataRepository; + this.usageInvoicingLogic = usageInvoicingLogic; + this.usageMolliePaymentLogic = usageMolliePaymentLogic; + } + + /// + /// Execute a usage invoicing action. + /// + /// Usage invoicing action. + /// Used. + [ProducesResponseType(typeof(Api.Used), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> PostUsageInvoicingAction([FromBody] Api.UsageInvoicingAction action) + { + if (settings.Payment?.EnablePayment != true || settings.Usage?.EnableInvoice != true) + { + throw new Exception("Payment not configured."); + } + + try + { + if (!await ModelState.TryValidateObjectAsync(action)) return BadRequest(ModelState); + action.TenantName = action.TenantName.ToLower(); + + var mUsed = await tenantDataRepository.GetAsync(await Used.IdFormatAsync(action.TenantName, action.PeriodBeginDate.Year, action.PeriodBeginDate.Month)); + + if (action.DoInvoicing) + { + await DoInvoicing(mUsed); + } + else if (action.DoSendInvoiceAgain) + { + await DoSendInvoiceAgain(mUsed); + } + else if (action.DoCreditNote) + { + await DoCreditNote(mUsed); + } + else if (action.DoSendCreditNoteAgain) + { + await DoSendCreditNoteAgain(mUsed); + } + else if (action.DoPaymentAgain) + { + await DoPaymentAgain(mUsed); + } + else + { + throw new InvalidOperationException(); + } + + mUsed = await tenantDataRepository.GetAsync(await Used.IdFormatAsync(action.TenantName, action.PeriodBeginDate.Year, action.PeriodBeginDate.Month)); + return Ok(mapper.Map(mUsed)); + } + catch (FoxIDsDataException ex) + { + if (ex.StatusCode == DataStatusCode.NotFound) + { + logger.Warning(ex, $"NotFound, Do '{typeof(Api.Used).Name}' action by tenant name '{action.TenantName}', year '{action.PeriodBeginDate.Year}' and month '{action.PeriodBeginDate.Month}'."); + return NotFound(typeof(Api.Used).Name, $"{action.TenantName}/{action.PeriodBeginDate.Year}/{action.PeriodBeginDate.Month}"); + } + throw; + } + } + + private async Task DoInvoicing(Used mUsed) + { + var mTenant = await tenantDataRepository.GetAsync(await Tenant.IdFormatAsync(mUsed.TenantName)); + + if (mTenant.EnableUsage) + { + if ((mUsed.IsUsageCalculated || mUsed.Items?.Count() > 0) && !mUsed.IsDone) + { + using var cancellationTokenSource = new CancellationTokenSource(); + await usageInvoicingLogic.DoInvoicingAsync(mTenant, mUsed, cancellationTokenSource.Token); + } + else + { + throw new Exception("The usage is not calculated or no items exits or it is already done and can not be invoiced."); + } + } + else + { + if(mUsed.IsDone) + { + throw new Exception("The usage is already done and can not be invoiced."); + } + else if (mUsed.Items?.Count() > 0) + { + using var cancellationTokenSource = new CancellationTokenSource(); + await usageInvoicingLogic.DoInvoicingAsync(mTenant, mUsed, cancellationTokenSource.Token, doInvoicing: true); + } + else + { + logger.Event($"Usage, no items to invoice for tenant '{mUsed.TenantName}'."); + } + } + } + + private async Task DoSendInvoiceAgain(Used mUsed) + { + if (mUsed.IsInvoiceReady) + { + var invoice = mUsed.Invoices.Last(); + if(!invoice.IsCardPayment || mUsed.PaymentStatus == UsagePaymentStatus.Paid) + { + await usageInvoicingLogic.SendInvoiceAsync(mUsed, invoice); + } + else + { + throw new Exception("The invoice is not paid and is not ready and can not be send again."); + } + } + else + { + throw new Exception("The invoice is not ready and can not be send again."); + } + } + + private async Task DoCreditNote(Used mUsed) + { + if (mUsed.IsInvoiceReady && mUsed.PaymentStatus == UsagePaymentStatus.None || mUsed.PaymentStatus.PaymentStatusIsGenerallyFailed()) + { + await usageInvoicingLogic.CreateAndSendCreditNoteAsync(mUsed); + } + else + { + throw new Exception("The invoice is not ready and a credit note can not be send."); + } + } + + private async Task DoSendCreditNoteAgain(Used mUsed) + { + var invoice = mUsed.Invoices.LastOrDefault(); + if (!mUsed.IsInvoiceReady && invoice?.IsCreditNote == true) + { + await usageInvoicingLogic.SendInvoiceAsync(mUsed, invoice); + } + else + { + throw new Exception("There is not already a credit note which can be send again."); + } + } + + private async Task DoPaymentAgain(Used mUsed) + { + if (mUsed.IsInvoiceReady) + { + if (mUsed.PaymentStatus.PaymentStatusIsGenerallyFailed()) + { + var mTenant = await tenantDataRepository.GetAsync(await Tenant.IdFormatAsync(mUsed.TenantName)); + var invoice = mUsed.Invoices.Last(); + await usageMolliePaymentLogic.DoPaymentAsync(mTenant, mUsed, invoice); + } + else + { + throw new Exception($"There is payment with status {mUsed.PaymentStatus} and payment is not possible."); + } + } + else + { + throw new Exception("The invoice is not ready and payment is not possible."); + } + } + } +} diff --git a/src/FoxIDs.Control/Extensions/PaymentStatusExtension.cs b/src/FoxIDs.Control/Extensions/PaymentStatusExtension.cs new file mode 100644 index 000000000..7a3871f17 --- /dev/null +++ b/src/FoxIDs.Control/Extensions/PaymentStatusExtension.cs @@ -0,0 +1,46 @@ +using FoxIDs.Models; +using Mollie.Api.Models.Payment; +using System; + +namespace FoxIDs +{ + public static class PaymentStatusExtension + { + public static UsagePaymentStatus FromMollieStatusToPaymentStatus(this string mollieStatus) + { + switch (mollieStatus) + { + case PaymentStatus.Open: + return UsagePaymentStatus.Open; + case PaymentStatus.Pending: + return UsagePaymentStatus.Pending; + case PaymentStatus.Authorized: + return UsagePaymentStatus.Authorized; + case PaymentStatus.Paid: + return UsagePaymentStatus.Paid; + case PaymentStatus.Canceled: + return UsagePaymentStatus.Canceled; + case PaymentStatus.Expired: + return UsagePaymentStatus.Expired; + case PaymentStatus.Failed: + return UsagePaymentStatus.Failed; + + default: + throw new NotSupportedException(); + } + } + + public static bool PaymentStatusIsGenerallyFailed(this UsagePaymentStatus status) + { + switch (status) + { + case UsagePaymentStatus.Canceled: + case UsagePaymentStatus.Expired: + case UsagePaymentStatus.Failed: + return true; + default: + return false; + } + } + } +} diff --git a/src/FoxIDs.Control/FoxIDs.Control.csproj b/src/FoxIDs.Control/FoxIDs.Control.csproj index 280e0f96d..cce40b12b 100644 --- a/src/FoxIDs.Control/FoxIDs.Control.csproj +++ b/src/FoxIDs.Control/FoxIDs.Control.csproj @@ -2,7 +2,7 @@ net8.0 - 1.10.16 + 1.11.21 FoxIDs Anders Revsgaard ITfoxtec @@ -27,6 +27,7 @@ + diff --git a/src/FoxIDs.Control/Infrastructure/Hosting/ServiceCollectionExtensions.cs b/src/FoxIDs.Control/Infrastructure/Hosting/ServiceCollectionExtensions.cs index 5fba55397..c1ca2c6f7 100644 --- a/src/FoxIDs.Control/Infrastructure/Hosting/ServiceCollectionExtensions.cs +++ b/src/FoxIDs.Control/Infrastructure/Hosting/ServiceCollectionExtensions.cs @@ -1,10 +1,11 @@ using AutoMapper; using Azure.Core; using Azure.Identity; -using FoxIDs.Infrastructure.Queue; using FoxIDs.Infrastructure.Security; using FoxIDs.Logic; +using FoxIDs.Logic.Queues; using FoxIDs.Logic.Seed; +using FoxIDs.Logic.Usage; using FoxIDs.MappingProfiles; using FoxIDs.Models; using FoxIDs.Models.Config; @@ -16,6 +17,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi.Models; +using Mollie.Api; +using Mollie.Api.Framework; using OpenSearch.Client; using OpenSearch.Net; using System; @@ -28,7 +31,7 @@ namespace FoxIDs.Infrastructure.Hosting { public static class ServiceCollectionExtensions { - public static IServiceCollection AddLogic(this IServiceCollection services, Settings settings) + public static IServiceCollection AddLogic(this IServiceCollection services, FoxIDsControlSettings settings) { services.AddSharedLogic(settings); @@ -38,6 +41,8 @@ public static IServiceCollection AddLogic(this IServiceCollection services, Sett services.AddTransient(); services.AddTransient(); + services.AddSingleton(); + services.AddHostedService(); services.AddTransient(); services.AddTransient(); @@ -82,10 +87,19 @@ public static IServiceCollection AddLogic(this IServiceCollection services, Sett services.AddTransient(); services.AddTransient(); + if (settings.Payment?.EnablePayment == true && settings.Usage?.EnableInvoice == true) + { + services.AddHostedService(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + } + return services; } - public static IServiceCollection AddRepository(this IServiceCollection services, Settings settings) + public static IServiceCollection AddRepository(this IServiceCollection services, FoxIDsControlSettings settings) { services.AddSharedRepository(settings); @@ -108,9 +122,6 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); - services.AddSingleton(); - services.AddHostedService(); - if (settings.Options.Log == LogOptions.ApplicationInsights || settings.Options.KeyStorage == KeyStorageOptions.KeyVault) { if (!environment.IsDevelopment()) @@ -139,6 +150,14 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddApiSwagger(); services.AddAutoMapper(); + if(settings.Payment?.EnablePayment == true && settings.Usage?.EnableInvoice == true) + { + services.AddMollieApi(options => { + options.ApiKey = settings.Payment.MollieApiKey; + options.RetryPolicy = MollieHttpRetryPolicies.TransientHttpErrorRetryPolicy(); + }); + } + return services; } @@ -230,6 +249,7 @@ public static IServiceCollection AddAutoMapper(this IServiceCollection services) mc.AddProfile(new MasterMappingProfile()); mc.AddProfile(new TenantMappingProfiles(httpContextAccessor)); + mc.AddProfile(new ExternalMappingProfile()); }); return mappingConfig.CreateMapper(); diff --git a/src/FoxIDs.Control/Logic/Logs/LogApplicationInsightsLogic.cs b/src/FoxIDs.Control/Logic/Logs/LogApplicationInsightsLogic.cs index ccd1c47e1..3f08fd39f 100644 --- a/src/FoxIDs.Control/Logic/Logs/LogApplicationInsightsLogic.cs +++ b/src/FoxIDs.Control/Logic/Logs/LogApplicationInsightsLogic.cs @@ -25,7 +25,7 @@ public LogApplicationInsightsLogic(FoxIDsControlSettings settings, LogAnalyticsW this.logAnalyticsWorkspaceProvider = logAnalyticsWorkspaceProvider; } - public async Task QueryLogs(Api.LogRequest logRequest, QueryTimeRange queryTimeRange, int maxResponseLogItems) + public async Task QueryLogsAsync(Api.LogRequest logRequest, QueryTimeRange queryTimeRange, int maxResponseLogItems) { var responseTruncated = false; var items = new List(); diff --git a/src/FoxIDs.Control/Logic/Logs/LogOpenSearchLogic.cs b/src/FoxIDs.Control/Logic/Logs/LogOpenSearchLogic.cs index 32a45d0e6..fd829721d 100644 --- a/src/FoxIDs.Control/Logic/Logs/LogOpenSearchLogic.cs +++ b/src/FoxIDs.Control/Logic/Logs/LogOpenSearchLogic.cs @@ -254,7 +254,7 @@ private async Task> LoadLogsAsync(Api.LogRequest private string GetIndexName() { - return $"{Constants.Logs.LogName}*"; + return $"{settings.OpenSearch.LogName}*"; } private IBoolQuery GetQuery(BoolQueryDescriptor boolQuery, Api.LogRequest logRequest, (DateTime start, DateTime end) queryTimeRange) diff --git a/src/FoxIDs.Control/Logic/Logs/UsageLogApplicationInsightsLogic.cs b/src/FoxIDs.Control/Logic/Logs/UsageLogApplicationInsightsLogic.cs index 433023d30..e59ee539d 100644 --- a/src/FoxIDs.Control/Logic/Logs/UsageLogApplicationInsightsLogic.cs +++ b/src/FoxIDs.Control/Logic/Logs/UsageLogApplicationInsightsLogic.cs @@ -25,7 +25,7 @@ public UsageLogApplicationInsightsLogic(FoxIDsControlSettings settings, LogAnaly this.logAnalyticsWorkspaceProvider = logAnalyticsWorkspaceProvider; } - public async Task> QueryLogs(Api.UsageLogRequest logRequest, string tenantName, string trackName, bool isMasterTenant, List items) + public async Task> QueryLogsAsync(Api.UsageLogRequest logRequest, string tenantName, string trackName, bool isMasterTenant, List items) { var dayPointer = 0; var hourPointer = 0; @@ -123,14 +123,14 @@ private Api.UsageLogTypes GetLogType(LogsTableRow row) return logType; } - private double GetCount(LogsTableRow row, Api.UsageLogTypes logType) + private decimal GetCount(LogsTableRow row, Api.UsageLogTypes logType) { var count = row.GetDouble("UsageCount"); if (logType == Api.UsageLogTypes.Login || logType == Api.UsageLogTypes.TokenRequest) { count += row.GetDouble("UsageAddRating"); } - return Math.Round(count.HasValue ? count.Value : 0, 1); + return count.HasValue ? Math.Round(Convert.ToDecimal(count.Value), 1) : 0.0M; } private DateTime GetDate(LogsTableRow row) diff --git a/src/FoxIDs.Control/Logic/Logs/UsageLogLogic.cs b/src/FoxIDs.Control/Logic/Logs/UsageLogLogic.cs index 80dde4f44..ac73e9996 100644 --- a/src/FoxIDs.Control/Logic/Logs/UsageLogLogic.cs +++ b/src/FoxIDs.Control/Logic/Logs/UsageLogLogic.cs @@ -26,26 +26,29 @@ public UsageLogLogic(FoxIDsControlSettings settings, IServiceProvider servicePro this.tenantDataRepository = tenantDataRepository; } - public async Task GetTrackUsageLog(Api.UsageLogRequest logRequest, string tenantName, string trackName, bool isMasterTenant = false, bool isMasterTrack = false) + public async Task GetTrackUsageLogAsync(Api.UsageLogRequest logRequest, string tenantName, string trackName, bool isMasterTenant = false, bool isMasterTrack = false) { - var items = await QueryDb(logRequest, tenantName, trackName, isMasterTenant, isMasterTrack); + var items = await QueryDbAsync(logRequest, tenantName, trackName, isMasterTenant, isMasterTrack); - switch (settings.Options.Log) + if (!logRequest.OnlyDbQuery) { - case LogOptions.OpenSearchAndStdoutErrors: - items = await serviceProvider.GetService().QueryLogs(logRequest, tenantName, trackName, items); - break; - case LogOptions.ApplicationInsights: - items = await serviceProvider.GetService().QueryLogs(logRequest, tenantName, trackName, isMasterTenant, items); - break; - default: - throw new NotSupportedException(); + switch (settings.Options.Log) + { + case LogOptions.OpenSearchAndStdoutErrors: + items = await serviceProvider.GetService().QueryLogsAsync(logRequest, tenantName, trackName, items); + break; + case LogOptions.ApplicationInsights: + items = await serviceProvider.GetService().QueryLogsAsync(logRequest, tenantName, trackName, isMasterTenant, items); + break; + default: + throw new NotSupportedException(); + } } return new Api.UsageLogResponse { SummarizeLevel = logRequest.SummarizeLevel, Items = SortUsageTypes(items) }; } - private async Task> QueryDb(Api.UsageLogRequest logRequest, string tenantName, string trackName, bool isMasterTenant, bool isMasterTrack) + private async Task> QueryDbAsync(Api.UsageLogRequest logRequest, string tenantName, string trackName, bool isMasterTenant, bool isMasterTrack) { var items = new List(); if (logRequest.TimeScope == Api.UsageLogTimeScopes.ThisMonth && logRequest.SummarizeLevel == Api.UsageLogSummarizeLevels.Month) diff --git a/src/FoxIDs.Control/Logic/Logs/UsageLogOpenSearchLogic.cs b/src/FoxIDs.Control/Logic/Logs/UsageLogOpenSearchLogic.cs index 7d1801743..31c4fcd88 100644 --- a/src/FoxIDs.Control/Logic/Logs/UsageLogOpenSearchLogic.cs +++ b/src/FoxIDs.Control/Logic/Logs/UsageLogOpenSearchLogic.cs @@ -23,7 +23,7 @@ public UsageLogOpenSearchLogic(FoxIDsControlSettings settings, OpenSearchClientQ this.openSearchClient = openSearchClient; } - public async Task> QueryLogs(Api.UsageLogRequest logRequest, string tenantName, string trackName, List items) + public async Task> QueryLogsAsync(Api.UsageLogRequest logRequest, string tenantName, string trackName, List items) { var dayPointer = 0; var hourPointer = 0; @@ -135,9 +135,9 @@ private Api.UsageLogTypes GetLogType(string usageType) return logType; } - private double GetCount(DateHistogramBucket bucketItem, Api.UsageLogTypes logType) + private decimal GetCount(DateHistogramBucket bucketItem, Api.UsageLogTypes logType) { - var count = bucketItem.DocCount.HasValue ? Convert.ToDouble(bucketItem.DocCount.Value) : 0.0; + var count = bucketItem.DocCount.HasValue ? bucketItem.DocCount.Value : 0.0; if (logType == Api.UsageLogTypes.Login || logType == Api.UsageLogTypes.TokenRequest) { var valueAggregate = bucketItem.Values.First() as ValueAggregate; @@ -146,12 +146,12 @@ private double GetCount(DateHistogramBucket bucketItem, Api.UsageLogTypes logTyp count += valueAggregate.Value.Value; } } - return Math.Round(count, 1); + return Math.Round(Convert.ToDecimal(count), 1); } private (DateTime start, DateTime end) GetQueryTimeRange(Api.UsageLogTimeScopes timeScope, int timeOffset) { - var timePointer = DateTimeOffset.Now; + var timePointer = DateTimeOffset.UtcNow; if (timeScope == Api.UsageLogTimeScopes.LastMonth) { timePointer = timePointer.AddMonths(-1); @@ -194,7 +194,7 @@ private async Task LoadUsageEventsAsync(string tenantName, str private string GetIndexName() { - return $"{Constants.Logs.LogName}*"; + return $"{settings.OpenSearch.LogName}*"; } private IBoolQuery GetQuery(BoolQueryDescriptor boolQuery, string tenantName, string trackName, (DateTime start, DateTime end) queryTimeRange) diff --git a/src/FoxIDs.Control/Logic/PartyLogic.cs b/src/FoxIDs.Control/Logic/PartyLogic.cs index 35834a697..4f6df6d5b 100644 --- a/src/FoxIDs.Control/Logic/PartyLogic.cs +++ b/src/FoxIDs.Control/Logic/PartyLogic.cs @@ -35,7 +35,7 @@ public async Task DeleteExporedDownParties() { var idKey = new Track.IdKey { TenantName = RouteBinding.TenantName, TrackName = RouteBinding.TrackName }; var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - await tenantDataRepository.DeleteListAsync(idKey, whereQuery: p => p.DataType.Equals(Constants.Models.DataType.DownParty) && p.IsTest == true && p.TestExpireAt < now); + await tenantDataRepository.DeleteListAsync(idKey, whereQuery: p => p.DataType.Equals(Constants.Models.DataType.DownParty) && p.IsTest == true && p.TestExpireAt > 0 && p.TestExpireAt < now); } } } diff --git a/src/FoxIDs.Control/Infrastructure/Queue/BackgroundQueue.cs b/src/FoxIDs.Control/Logic/Queues/BackgroundQueue.cs similarity index 96% rename from src/FoxIDs.Control/Infrastructure/Queue/BackgroundQueue.cs rename to src/FoxIDs.Control/Logic/Queues/BackgroundQueue.cs index 2185eb25e..222641f5a 100644 --- a/src/FoxIDs.Control/Infrastructure/Queue/BackgroundQueue.cs +++ b/src/FoxIDs.Control/Logic/Queues/BackgroundQueue.cs @@ -3,7 +3,7 @@ using System.Threading; using System; -namespace FoxIDs.Infrastructure.Queue +namespace FoxIDs.Logic.Queues { public class BackgroundQueue { diff --git a/src/FoxIDs.Control/Logic/Queues/DownPartyAllowUpPartiesQueueLogic.cs b/src/FoxIDs.Control/Logic/Queues/DownPartyAllowUpPartiesQueueLogic.cs index b300823b1..e1d48cc1f 100644 --- a/src/FoxIDs.Control/Logic/Queues/DownPartyAllowUpPartiesQueueLogic.cs +++ b/src/FoxIDs.Control/Logic/Queues/DownPartyAllowUpPartiesQueueLogic.cs @@ -1,8 +1,7 @@ using FoxIDs.Infrastructure; -using FoxIDs.Infrastructure.Queue; using FoxIDs.Models; using Api = FoxIDs.Models.Api; -using FoxIDs.Models.Queue; +using FoxIDs.Models.Queues; using FoxIDs.Repository; using ITfoxtec.Identity; using Microsoft.AspNetCore.Http; @@ -12,7 +11,7 @@ using System.Threading; using System.Threading.Tasks; -namespace FoxIDs.Logic +namespace FoxIDs.Logic.Queues { public class DownPartyAllowUpPartiesQueueLogic : LogicBase { @@ -202,6 +201,14 @@ private void StartWork(string upPartyName, IEnumerable m await DoWorkAsync(routeBinding.TenantName, routeBinding.TrackName, upPartyName, messages, stoppingToken); logger.Event($"Done processing '{info}'."); } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } catch (Exception ex) { logger.Error(ex, "Background queue error."); @@ -216,7 +223,6 @@ public async Task DoWorkAsync(string tenantName, string trackName, string upPart while (!stoppingToken.IsCancellationRequested) { (var downParties, paginationToken) = await tenantDataRepository.GetListAsync(idKey, whereQuery: p => p.DataType == Constants.Models.DataType.DownParty && p.AllowUpParties.Where(up => up.Name == upPartyName).Any(), pageSize: 100, paginationToken: paginationToken, scopedLogger: logger); - stoppingToken.ThrowIfCancellationRequested(); foreach (var downParty in downParties) { stoppingToken.ThrowIfCancellationRequested(); diff --git a/src/FoxIDs.Control/Infrastructure/Queue/BackgroundQueueService.cs b/src/FoxIDs.Control/Logic/Queues/QueueBackgroundService.cs similarity index 82% rename from src/FoxIDs.Control/Infrastructure/Queue/BackgroundQueueService.cs rename to src/FoxIDs.Control/Logic/Queues/QueueBackgroundService.cs index b475be28e..bf4f0974a 100644 --- a/src/FoxIDs.Control/Infrastructure/Queue/BackgroundQueueService.cs +++ b/src/FoxIDs.Control/Logic/Queues/QueueBackgroundService.cs @@ -1,16 +1,17 @@ -using Microsoft.Extensions.Hosting; +using FoxIDs.Infrastructure; +using Microsoft.Extensions.Hosting; using System; using System.Threading; using System.Threading.Tasks; -namespace FoxIDs.Infrastructure.Queue +namespace FoxIDs.Logic.Queues { - public class BackgroundQueueService : BackgroundService + public class QueueBackgroundService : BackgroundService { private readonly TelemetryLogger logger; private readonly BackgroundQueue backgroundQueue; - public BackgroundQueueService(TelemetryLogger logger, BackgroundQueue backgroundQueue) + public QueueBackgroundService(TelemetryLogger logger, BackgroundQueue backgroundQueue) { this.logger = logger; this.backgroundQueue = backgroundQueue; diff --git a/src/FoxIDs.Control/Logic/Seed/MainTenantDocumentsSeedLogic.cs b/src/FoxIDs.Control/Logic/Seed/MainTenantDocumentsSeedLogic.cs index d688165f2..6e0e0cd29 100644 --- a/src/FoxIDs.Control/Logic/Seed/MainTenantDocumentsSeedLogic.cs +++ b/src/FoxIDs.Control/Logic/Seed/MainTenantDocumentsSeedLogic.cs @@ -65,6 +65,7 @@ private async Task CreateAndValidateMainTenantDocumentAsync(string foxIDsE mainTenant.CustomDomain = foxIDsEndpoint.UrlToDomain(); mainTenant.CustomDomainVerified = true; } + mainTenant.CreateTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); await tenantDataRepository.CreateAsync(mainTenant); return true; } diff --git a/src/FoxIDs.Control/Logic/Seed/MasterTenantDocumentsSeedLogic.cs b/src/FoxIDs.Control/Logic/Seed/MasterTenantDocumentsSeedLogic.cs index 6eda7b1f5..ef0d250c7 100644 --- a/src/FoxIDs.Control/Logic/Seed/MasterTenantDocumentsSeedLogic.cs +++ b/src/FoxIDs.Control/Logic/Seed/MasterTenantDocumentsSeedLogic.cs @@ -57,6 +57,7 @@ private async Task CreateAndValidateMasterTenantDocumentAsync() return false; } + masterTenant.CreateTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); await tenantDataRepository.CreateAsync(masterTenant); return true; } diff --git a/src/FoxIDs.Control/Logic/Usage/UsageBackgroundService.cs b/src/FoxIDs.Control/Logic/Usage/UsageBackgroundService.cs new file mode 100644 index 000000000..4b7e75c77 --- /dev/null +++ b/src/FoxIDs.Control/Logic/Usage/UsageBackgroundService.cs @@ -0,0 +1,96 @@ +using FoxIDs.Infrastructure; +using FoxIDs.Models.Config; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace FoxIDs.Logic.Usage +{ + public class UsageBackgroundService : BackgroundService + { + private const int oneHourWaitPeriodInSeconds = 60 * 60; + + private readonly FoxIDsControlSettings settings; + private readonly TelemetryLogger logger; + private readonly IServiceProvider serviceProvider; + + public UsageBackgroundService(TelemetryLogger logger, FoxIDsControlSettings settings, IServiceProvider serviceProvider) + { + this.settings = settings; + this.logger = logger; + this.serviceProvider = serviceProvider; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + if (settings.Payment.EnablePayment == true && settings.Usage?.EnableInvoice == true) + { + do + { + var waitPeriod = await ExecuteScopeAsync(stoppingToken); + await Task.Delay(waitPeriod, stoppingToken); + } + while (!stoppingToken.IsCancellationRequested); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception ex) + { + logger.Error(ex, "Using, background worker error."); + } + } + + protected async Task ExecuteScopeAsync(CancellationToken stoppingToken) + { + using (IServiceScope scope = serviceProvider.CreateScope()) + { + var scopedLogger = scope.ServiceProvider.GetRequiredService(); + try + { + scopedLogger.SetScopeProperty(Constants.Logs.MachineName, Environment.MachineName); + scopedLogger.SetScopeProperty(Constants.Logs.OperationId, Guid.NewGuid().ToString().Replace("-", string.Empty)); + scopedLogger.SetScopeProperty(Constants.Logs.TenantName, Constants.Routes.MasterTenantName); + scopedLogger.SetScopeProperty(Constants.Logs.TrackName, Constants.Routes.MasterTrackName); + scopedLogger.Event("Usage, background scope worker start."); + + if (await scope.ServiceProvider.GetRequiredService().DoWorkAsync(stoppingToken)) + { + var now = DateTime.Now; + var endOfMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0).AddMonths(1).AddSeconds(-1); + var timeSpanToEndOfMonth = endOfMonth - now; + var waitPeriodDone = timeSpanToEndOfMonth + TimeSpan.FromSeconds(oneHourWaitPeriodInSeconds); + scopedLogger.Event($"Usage, background scope worker tasks done, wait period {waitPeriodDone}."); + return waitPeriodDone; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception ex) + { + scopedLogger.Error(ex, "Using, background scope worker error."); + } + + var waitPeriod = TimeSpan.FromSeconds(oneHourWaitPeriodInSeconds); + scopedLogger.Event($"Usage, background scope worker end, wait period {waitPeriod}."); + return waitPeriod; + } + } + } +} diff --git a/src/FoxIDs.Control/Logic/Usage/UsageBackgroundWorkLogic.cs b/src/FoxIDs.Control/Logic/Usage/UsageBackgroundWorkLogic.cs new file mode 100644 index 000000000..917eceef7 --- /dev/null +++ b/src/FoxIDs.Control/Logic/Usage/UsageBackgroundWorkLogic.cs @@ -0,0 +1,297 @@ +using FoxIDs.Infrastructure; +using FoxIDs.Logic.Caches.Providers; +using FoxIDs.Models; +using FoxIDs.Models.Config; +using FoxIDs.Repository; +using Microsoft.AspNetCore.Http; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace FoxIDs.Logic.Usage +{ + public class UsageBackgroundWorkLogic : LogicBase + { + private const int oneDayLifetimeInSeconds = 60 * 60 * 24; + private const int twoMonthLifetimeInSeconds = 60 * 60 * 24 * 62; + private const int waitForOthersToRunInSeconds = 10; + private const int notUseToManyResourcesInSeconds = 5; + private const int loadPageSize = 10; + + private readonly DateTimeOffset currentMonthPointer; + private readonly DateOnly invoicingDatePointer; + + private readonly TelemetryScopedLogger scopedLogger; + private readonly FoxIDsControlSettings settings; + private readonly ICacheProvider cacheProvider; + private readonly ITenantDataRepository tenantDataRepository; + private readonly UsageCalculatorLogic usageCalculatorLogic; + private readonly UsageInvoicingLogic usageInvoicingLogic; + + public UsageBackgroundWorkLogic(TelemetryScopedLogger scopedLogger, FoxIDsControlSettings settings, ICacheProvider cacheProvider, ITenantDataRepository tenantDataRepository, UsageCalculatorLogic usageCalculatorLogic, UsageInvoicingLogic usageInvoicingLogic, IHttpContextAccessor httpContextAccessor) : base(httpContextAccessor) + { + this.scopedLogger = scopedLogger; + this.settings = settings; + this.cacheProvider = cacheProvider; + this.tenantDataRepository = tenantDataRepository; + this.usageCalculatorLogic = usageCalculatorLogic; + this.usageInvoicingLogic = usageInvoicingLogic; + + var now = DateTime.Now; + currentMonthPointer = new DateTimeOffset(new DateTime(now.Year, now.Month, 1, 0, 0, 0)); + invoicingDatePointer = new DateOnly(now.Year, now.Month, 1).AddMonths(-1); + } + + public async Task DoWorkAsync(CancellationToken stoppingToken) + { + if (await CacheExistsAsync(UsageMonthDoneKey)) + { + return true; + } + + if (!await CacheExistsAsync(UsageDoWorkKey) && !await CacheExistsAsync(UsageDoWorkWaitKey)) + { + var myId = Guid.NewGuid().ToString(); + await CacheSetAsync(UsageDoWorkKey, myId, oneDayLifetimeInSeconds); + + // wait, for others to override + await Task.Delay(1000 * waitForOthersToRunInSeconds, stoppingToken); // 10 seconds + + var keyId = await CacheGetAsync(UsageDoWorkKey); + if (myId == keyId) + { + var tasksDone = false; + if (!await CacheExistsAsync(UsageMonthCalculatedKey)) + { + (var calculatonTasksDone, var invoicingTasksDone) = await DoWorkInnerByTenantAsync(stoppingToken); + if (calculatonTasksDone) + { + await CacheSetFlagAsync(UsageMonthCalculatedKey, twoMonthLifetimeInSeconds); + } + if (calculatonTasksDone && invoicingTasksDone) + { + tasksDone = true; + } + } + else + { + if (await DoWorkInnerByUsedAsync(stoppingToken)) + { + tasksDone = true; + } + } + + if(tasksDone) + { + await CacheSetFlagAsync(UsageMonthDoneKey, twoMonthLifetimeInSeconds); + } + else + { + await CacheSetFlagAsync(UsageDoWorkWaitKey, settings.Usage.BackgroundServiceWaitPeriod); + } + + await CacheDeleteAsync(UsageDoWorkKey); + return tasksDone; + } + } + return false; + } + + private async Task CacheExistsAsync(string key) + { + var exist = await cacheProvider.ExistsAsync(key); + scopedLogger.Event($"Usage, do work, cache key '{key}' {(exist ? string.Empty : "NOT ")}exist."); + return exist; + } + + private async Task CacheSetAsync(string key, string value, int lifetime) + { + await cacheProvider.SetAsync(key, value, lifetime); + scopedLogger.Event($"Usage, do work, set cache key '{key}' value '{value}' with lifetime {lifetime}."); + } + + private async Task CacheSetFlagAsync(string key, int lifetime) + { + await cacheProvider.SetFlagAsync(key, lifetime); + scopedLogger.Event($"Usage, do work, set flag cache key '{key}' with lifetime {lifetime}."); + } + + private async Task CacheGetAsync(string key) + { + var value = await cacheProvider.GetAsync(key); + scopedLogger.Event($"Usage, do work, get cache key '{key}' has value '{value}'."); + return value; + } + + private async Task CacheDeleteAsync(string key) + { + await cacheProvider.DeleteAsync(key); + scopedLogger.Event($"Usage, do work, delete cache key '{key}'."); + } + + private async Task<(bool calculatonTasksDone, bool invoicingTasksDone)> DoWorkInnerByTenantAsync(CancellationToken stoppingToken) + { + try + { + var calculatonTasksDone = false; + var invoicingTasksDone = false; + scopedLogger.Event("Usage, calculation and invoicing stated by query tenants."); + + string paginationToken = null; + while (!stoppingToken.IsCancellationRequested) + { + var currentMonthStartingPoint = currentMonthPointer.ToUnixTimeSeconds(); + (var tenants, paginationToken) = await tenantDataRepository.GetListAsync(whereQuery: t => !string.IsNullOrEmpty(t.PlanName) && t.PlanName != "free" && (!t.CreateTime.HasValue || t.CreateTime.Value < currentMonthStartingPoint), pageSize: loadPageSize, paginationToken: paginationToken); + foreach (var tenant in tenants) + { + try + { + stoppingToken.ThrowIfCancellationRequested(); + var used = await usageCalculatorLogic.DoCalculationAsync(invoicingDatePointer, tenant, stoppingToken); + calculatonTasksDone = true; + + stoppingToken.ThrowIfCancellationRequested(); + if (await usageInvoicingLogic.DoInvoicingAsync(tenant, used, stoppingToken)) + { + invoicingTasksDone = true; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception tEx) + { + scopedLogger.Error(tEx, $"Usage, calculation and invoicing for tenant '{tenant.Name}' error."); + } + } + + if (paginationToken == null) + { + break; + } + + // sleep to not use to many resources + await Task.Delay(new TimeSpan(0, 0, notUseToManyResourcesInSeconds), stoppingToken); + } + + if (calculatonTasksDone) + { + scopedLogger.Event("Usage, calculation done."); + } + else + { + scopedLogger.Event("Usage, calculation NOT done."); + } + if (invoicingTasksDone) + { + scopedLogger.Event("Usage, invoicing done."); + } + else + { + scopedLogger.Event("Usage, invoicing NOT done."); + } + + return (calculatonTasksDone, invoicingTasksDone); + } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception ex) + { + scopedLogger.Error(ex, "Usage, calculation and invoicing error."); + return (false, false); + } + } + + private async Task DoWorkInnerByUsedAsync(CancellationToken stoppingToken) + { + try + { + var invoicingTasksDone = false; + scopedLogger.Event("Usage, invoicing stated by query used items."); + + string paginationToken = null; + while (!stoppingToken.IsCancellationRequested) + { + (var usedList, paginationToken) = await tenantDataRepository.GetListAsync(whereQuery: u => !u.IsDone, pageSize: loadPageSize, paginationToken: paginationToken); + foreach (var used in usedList) + { + try + { + stoppingToken.ThrowIfCancellationRequested(); + var tenant = await tenantDataRepository.GetAsync(await Tenant.IdFormatAsync(used.TenantName)); + if (await usageInvoicingLogic.DoInvoicingAsync(tenant, used, stoppingToken)) + { + invoicingTasksDone = true; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception tEx) + { + scopedLogger.Error(tEx, $"Usage, invoicing for tenant '{used.TenantName}' error."); + } + } + + if (paginationToken == null) + { + break; + } + + // sleep to not use to many resources + await Task.Delay(new TimeSpan(0, 0, notUseToManyResourcesInSeconds), stoppingToken); + } + + if (invoicingTasksDone) + { + scopedLogger.Event("Usage, invoicing done."); + } + else + { + scopedLogger.Event("Usage, invoicing NOT done."); + } + + return invoicingTasksDone; + } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception ex) + { + scopedLogger.Error(ex, "Usage, invoicing for tenant error."); + return false; + } + } + + private string UsageDoWorkKey => $"usage_do_work_{SubKey}"; + + private string UsageDoWorkWaitKey => $"usage_do_work_wait_{SubKey}"; + + private string UsageMonthCalculatedKey => $"usage_month_calculated_{SubKey}"; + + private string UsageMonthDoneKey => $"usage_month_done_{SubKey}"; + + private string SubKey => $"y:{invoicingDatePointer.Year}-m:{invoicingDatePointer.Month}"; + } +} diff --git a/src/FoxIDs.Control/Logic/Usage/UsageCalculatorLogic.cs b/src/FoxIDs.Control/Logic/Usage/UsageCalculatorLogic.cs new file mode 100644 index 000000000..04be35795 --- /dev/null +++ b/src/FoxIDs.Control/Logic/Usage/UsageCalculatorLogic.cs @@ -0,0 +1,92 @@ +using FoxIDs.Infrastructure; +using Api = FoxIDs.Models.Api; +using FoxIDs.Models; +using FoxIDs.Repository; +using Microsoft.AspNetCore.Http; +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Linq; + +namespace FoxIDs.Logic.Usage +{ + public class UsageCalculatorLogic : LogicBase + { + private readonly TelemetryScopedLogger logger; + private readonly ITenantDataRepository tenantDataRepository; + private readonly UsageLogLogic usageLogLogic; + + public UsageCalculatorLogic(TelemetryScopedLogger logger, ITenantDataRepository tenantDataRepository, UsageLogLogic usageLogLogic, IHttpContextAccessor httpContextAccessor) : base(httpContextAccessor) + { + this.logger = logger; + this.tenantDataRepository = tenantDataRepository; + this.usageLogLogic = usageLogLogic; + } + + public async Task DoCalculationAsync(DateOnly datePointer, Tenant tenant, CancellationToken stoppingToken) + { + var id = await Used.IdFormatAsync(new Used.IdKey { TenantName = tenant.Name, PeriodYear = datePointer.Year, PeriodMonth = datePointer.Month }); + var used = await tenantDataRepository.GetAsync(id, required: false); + if (used?.IsUsageCalculated == true) + { + return used; + } + + logger.Event($"Usage, calculation for tenant '{tenant.Name}' started."); + + if (used == null) + { + used = new Used + { + Id = id, + TenantName = tenant.Name + }; + + used.PeriodBeginDate = new DateOnly(datePointer.Year, datePointer.Month, 1); + used.PeriodEndDate = used.PeriodBeginDate.AddMonths(1).AddDays(-1); + if (tenant.CreateTime.HasValue && tenant.CreateTime.Value > 0) + { + var tenantCreateDate = DateOnly.FromDateTime(DateTimeOffset.FromUnixTimeSeconds(tenant.CreateTime.Value).LocalDateTime); + used.PeriodBeginDate = used.PeriodBeginDate < tenantCreateDate && tenantCreateDate < used.PeriodEndDate ? tenantCreateDate : used.PeriodBeginDate; + } + } + + stoppingToken.ThrowIfCancellationRequested(); + var usageDbLogs = await usageLogLogic.GetTrackUsageLogAsync( + new Api.UsageLogRequest + { + OnlyDbQuery = true, + TimeScope = Api.UsageLogTimeScopes.ThisMonth, + SummarizeLevel = Api.UsageLogSummarizeLevels.Month, + IncludeTracks = true, + IncludeUsers = true, + }, tenant.Name, null, isMasterTrack: true); + + stoppingToken.ThrowIfCancellationRequested(); + var usageLogs = await usageLogLogic.GetTrackUsageLogAsync( + new Api.UsageLogRequest + { + TimeScope = Api.UsageLogTimeScopes.LastMonth, + SummarizeLevel = Api.UsageLogSummarizeLevels.Month, + IncludeLogins = true, + IncludeTokenRequests = true, + IncludeControlApiGets = true, + IncludeControlApiUpdates = true, + }, tenant.Name, null, isMasterTrack: true); + + used.IsUsageCalculated = true; + used.Tracks = usageDbLogs.Items.Where(i => i.Type == Api.UsageLogTypes.Track).Select(i => i.Value).FirstOrDefault(); + used.Users = usageDbLogs.Items.Where(i => i.Type == Api.UsageLogTypes.User).Select(i => i.Value).FirstOrDefault(); + used.Logins = usageLogs.Items.Where(i => i.Type == Api.UsageLogTypes.Login).Select(i => i.Value).FirstOrDefault(); + used.TokenRequests = usageLogs.Items.Where(i => i.Type == Api.UsageLogTypes.TokenRequest).Select(i => i.Value).FirstOrDefault(); + used.ControlApiGets = usageLogs.Items.Where(i => i.Type == Api.UsageLogTypes.ControlApiGet).Select(i => i.Value).FirstOrDefault(); + used.ControlApiUpdates = usageLogs.Items.Where(i => i.Type == Api.UsageLogTypes.ControlApiUpdate).Select(i => i.Value).FirstOrDefault(); + + stoppingToken.ThrowIfCancellationRequested(); + await tenantDataRepository.SaveAsync(used); + + logger.Event($"Usage, calculation for tenant '{tenant.Name}' done."); + return used; + } + } +} diff --git a/src/FoxIDs.Control/Logic/Usage/UsageInvoicingLogic.cs b/src/FoxIDs.Control/Logic/Usage/UsageInvoicingLogic.cs new file mode 100644 index 000000000..0385eb224 --- /dev/null +++ b/src/FoxIDs.Control/Logic/Usage/UsageInvoicingLogic.cs @@ -0,0 +1,460 @@ +using AutoMapper; +using FoxIDs.Infrastructure; +using FoxIDs.Models; +using FoxIDs.Models.Config; +using FoxIDs.Repository; +using FoxIDs.Util; +using ITfoxtec.Identity; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ExtInv = FoxIDs.Models.ExternalInvoices; + +namespace FoxIDs.Logic.Usage +{ + public class UsageInvoicingLogic : LogicBase + { + private readonly FoxIDsControlSettings settings; + private readonly TelemetryScopedLogger logger; + private readonly IHttpClientFactory httpClientFactory; + private readonly IMapper mapper; + private readonly IMasterDataRepository masterDataRepository; + private readonly ITenantDataRepository tenantDataRepository; + private readonly UsageMolliePaymentLogic usageMolliePaymentLogic; + + public UsageInvoicingLogic(FoxIDsControlSettings settings, TelemetryScopedLogger logger, IHttpClientFactory httpClientFactory, IMapper mapper, IMasterDataRepository masterDataRepository, ITenantDataRepository tenantDataRepository, UsageMolliePaymentLogic usageMolliePaymentLogic, IHttpContextAccessor httpContextAccessor) : base(httpContextAccessor) + { + this.settings = settings; + this.logger = logger; + this.httpClientFactory = httpClientFactory; + this.mapper = mapper; + this.masterDataRepository = masterDataRepository; + this.tenantDataRepository = tenantDataRepository; + this.usageMolliePaymentLogic = usageMolliePaymentLogic; + } + + public async Task DoInvoicingAsync(Tenant tenant, Used used, CancellationToken stoppingToken, bool doInvoicing = false) + { + if(!doInvoicing && !tenant.EnableUsage) + { + logger.Event($"Usage, invoicing for tenant '{used.TenantName}' not enabled."); + return false; + } + + var taskDone = true; + var isCardPayment = usageMolliePaymentLogic.HasCardPayment(tenant); + + logger.Event($"Usage, {EventNameText(isCardPayment)} invoicing for tenant '{used.TenantName}' started."); + + (var invoiceTaskDone, var invoice) = await GetInvoiceAsync(tenant, used, isCardPayment, stoppingToken); + if (!invoiceTaskDone) + { + taskDone = false; + } + + if (taskDone) + { + stoppingToken.ThrowIfCancellationRequested(); + if (isCardPayment) + { + if (!usageMolliePaymentLogic.HasActiveCardPayment(tenant)) + { + try + { + throw new Exception("Card payment NOT active."); + } + catch (Exception ex) + { + logger.Error(ex); + taskDone = false; + } + } + else + { + if (used.PaymentStatus == UsagePaymentStatus.None) + { + if (!await usageMolliePaymentLogic.DoPaymentAsync(tenant, used, invoice)) + { + taskDone = false; + } + } + else if (used.PaymentStatus == UsagePaymentStatus.Open || used.PaymentStatus == UsagePaymentStatus.Pending || used.PaymentStatus == UsagePaymentStatus.Authorized) + { + if (!await usageMolliePaymentLogic.UpdatePaymentAsync(used)) + { + taskDone = false; + } + } + + if (taskDone) + { + stoppingToken.ThrowIfCancellationRequested(); + if (invoice.SendStatus == UsageInvoiceSendStatus.None && used.PaymentStatus == UsagePaymentStatus.Paid) + { + if (!await SendInvoiceAsync(used, invoice)) + { + taskDone = false; + } + } + } + } + } + else + { + if (invoice.SendStatus == UsageInvoiceSendStatus.None) + { + if (!await SendInvoiceAsync(used, invoice)) + { + taskDone = false; + } + } + } + } + + if(taskDone) + { + used.IsDone = true; + await tenantDataRepository.UpdateAsync(used); + logger.Event($"Usage, {EventNameText(isCardPayment)} invoicing for tenant '{used.TenantName}' done."); + } + return taskDone; + } + + public async Task CreateAndSendCreditNoteAsync(Used used) + { + if (used.PaymentStatus != UsagePaymentStatus.None && !used.PaymentStatus.PaymentStatusIsGenerallyFailed()) + { + throw new Exception("Invalid payment status."); + } + + logger.Event($"Usage, create and send credit note for tenant '{used.TenantName}' started."); + + var invoice = used.Invoices.LastOrDefault(); + if (invoice == null || invoice.IsCreditNote) + { + throw new Exception("Invalid last invoice."); + } + + var creditNote = new Invoice + { + IsCreditNote = true, + InvoiceNumber = await GetInvoiceNumberAsync(await GetUsageSettingsAsync()), + IsCardPayment = invoice.IsCardPayment, + IssueDate = DateOnly.FromDateTime(DateTime.Now), + Seller = mapper.Map(settings.Usage.Seller), + Customer = invoice.Customer, + Currency = invoice.Currency, + Lines = invoice.Lines, + Price = invoice.Price, + Vat = invoice.Vat, + TotalPrice = invoice.TotalPrice, + }; + + used.IsInvoiceReady = false; + used.IsDone = false; + used.Invoices.Add(creditNote); + await tenantDataRepository.UpdateAsync(used); + + logger.Event($"Usage, create {EventNameText(creditNote.IsCardPayment)} credit note for tenant '{used.TenantName}' done."); + + _ = await SendInvoiceAsync(used, creditNote); + + logger.Event($"Usage, send {EventNameText(creditNote.IsCardPayment)} credit note for tenant '{used.TenantName}' done."); + } + + public async Task SendInvoiceAsync(Used used, Invoice invoice) + { + try + { + logger.Event($"Usage, send {EventNameText(invoice.IsCardPayment)} invoice for tenant '{used.TenantName}' started."); + await CallExternalMakeInvoiceAsync(used, invoice, sendInvoice: true); + + invoice.SendStatus = UsageInvoiceSendStatus.Send; + await tenantDataRepository.UpdateAsync(used); + logger.Event($"Usage, send {EventNameText(invoice.IsCardPayment)} invoice for tenant '{used.TenantName}' done."); + return true; + } + catch (Exception ex) + { + try + { + invoice.SendStatus = UsageInvoiceSendStatus.Failed; + await tenantDataRepository.UpdateAsync(used); + } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception saveEx) + { + logger.Error(saveEx, $"Usage, unable to save status: {UsageInvoiceSendStatus.Failed}."); + } + logger.Error(ex, $"Usage, send {EventNameText(invoice.IsCardPayment)} invoice for tenant '{used.TenantName}' error."); + return false; + } + } + + private async Task<(bool taskDone, Invoice invoice)> GetInvoiceAsync(Tenant tenant, Used used, bool isCardPayment, CancellationToken stoppingToken) + { + try + { + if (used.IsInvoiceReady) + { + return (true, used.Invoices.Last()); + } + else + { + logger.Event($"Usage, create {EventNameText(isCardPayment)} invoice for tenant '{used.TenantName}' started."); + var invoice = await CreateInvoiceAsync(tenant, used, isCardPayment, stoppingToken); + logger.Event($"Usage, create {EventNameText(isCardPayment)} invoice for tenant '{used.TenantName}' done."); + return (true, invoice); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception ex) + { + logger.Error(ex, $"Usage, create {EventNameText(isCardPayment)} invoice for tenant '{used.TenantName}' error."); + return (false, null); + } + } + + private async Task CreateInvoiceAsync(Tenant tenant, Used used, bool isCardPayment, CancellationToken stoppingToken) + { + var invoice = new Invoice + { + IsCardPayment = isCardPayment, + IssueDate = DateOnly.FromDateTime(DateTime.Now), + Seller = mapper.Map(settings.Usage.Seller), + Customer = tenant.Customer, + Currency = tenant.Currency.IsNullOrEmpty() ? Constants.Models.Currency.Eur : tenant.Currency, + BankDetails = settings.Usage.Seller.BankDetails, + }; + + var usageSettings = await GetUsageSettingsAsync(); + var plan = tenant.EnableUsage && !tenant.PlanName.IsNullOrEmpty() ? await masterDataRepository.GetAsync(await Plan.IdFormatAsync(tenant.PlanName)) : null; + + stoppingToken.ThrowIfCancellationRequested(); + CalculateInvoice(used, plan, invoice, tenant.IncludeVat, GetExchangesRate(invoice.Currency, usageSettings.CurrencyExchanges)); + + invoice.InvoiceNumber = await GetInvoiceNumberAsync(usageSettings); + + if (!isCardPayment) + { + invoice.DueDate = invoice.IssueDate.AddDays(settings.Usage.Seller.PaymentDueDays); + } + + await invoice.ValidateObjectAsync(); + used.IsInvoiceReady = true; + if (used.Invoices?.Count > 0) + { + used.Invoices.Add(invoice); + } + else + { + used.Invoices = [invoice]; + } + stoppingToken.ThrowIfCancellationRequested(); + await tenantDataRepository.UpdateAsync(used); + return invoice; + } + + private async Task GetUsageSettingsAsync() + { + var usageSettings = await masterDataRepository.GetAsync(await UsageSettings.IdFormatAsync(), required: false); + if (usageSettings == null) + { + usageSettings = new UsageSettings + { + Id = await UsageSettings.IdFormatAsync() + }; + await masterDataRepository.CreateAsync(usageSettings); + } + + return usageSettings; + } + + private async Task GetInvoiceNumberAsync(UsageSettings usageSettings) + { + usageSettings.InvoiceNumber += 1; + await masterDataRepository.UpdateAsync(usageSettings); + + var fullInvoiceNumber = $"{(usageSettings.InvoiceNumberPrefix.IsNullOrWhiteSpace() ? string.Empty : usageSettings.InvoiceNumberPrefix)}{usageSettings.InvoiceNumber}"; + return fullInvoiceNumber; + } + + private decimal GetExchangesRate(string currency, List currencyExchanges) + { + if (currency == Constants.Models.Currency.Eur) + { + return 1; + } + else + { + var currencyExchange = currencyExchanges?.Where(e => e.Currency == currency).FirstOrDefault(); + if (currencyExchange == null) + { + throw new Exception($"Missing currency exchange for '{currency}'."); + } + + return currencyExchange.Rate; + } + } + + private void CalculateInvoice(Used used, Plan plan, Invoice invoice, bool includeVat, decimal exchangeRate) + { + invoice.Lines = new List(); + + if (plan != null) + { + invoice.IncludesUsage = true; + + (var planPrice, var planInfoText) = MonthUsagePrice(used, plan, exchangeRate); + invoice.Lines.Add(new InvoiceLine { Text = $"FoxIDs {plan.DisplayName ?? plan.Name} plan{planInfoText}", Quantity = 1, UnitPrice = planPrice, Price = planPrice }); + invoice.Price += planPrice; + + invoice.Price += AddInvoiceUsageLine(invoice.Lines, $"Additional environments ({plan.Tracks.Included} included)", $"Additional environments (more then {plan.Tracks.FirstLevelThreshold})", used.Tracks, plan.Tracks, exchangeRate); + invoice.Price += AddInvoiceUsageLine(invoice.Lines, $"Additional users ({plan.Users.Included} included)", $"Additional users (more then {plan.Users.FirstLevelThreshold})", used.Users, plan.Users, exchangeRate); + invoice.Price += AddInvoiceUsageLine(invoice.Lines, $"Additional logins ({plan.Logins.Included} included)", $"Additional logins (more then {plan.Logins.FirstLevelThreshold})", used.Logins, plan.Logins, exchangeRate); + invoice.Price += AddInvoiceUsageLine(invoice.Lines, $"Additional token requests ({plan.TokenRequests.Included} included)", $"Additional token requests (more then {plan.TokenRequests.FirstLevelThreshold})", used.TokenRequests, plan.TokenRequests, exchangeRate); + invoice.Price += AddInvoiceUsageLine(invoice.Lines, $"Additional Control API reads ({plan.ControlApiGetRequests.Included} included)", $"Additional Control API reads (more then {plan.ControlApiGetRequests.FirstLevelThreshold})", used.ControlApiGets, plan.ControlApiGetRequests, exchangeRate); + invoice.Price += AddInvoiceUsageLine(invoice.Lines, $"Additional Control API updates ({plan.ControlApiUpdateRequests.Included} included)", $"Additional Control API updates (more then {plan.ControlApiUpdateRequests.FirstLevelThreshold})", used.ControlApiUpdates, plan.ControlApiUpdateRequests, exchangeRate); + } + + if (used.Items?.Count() > 0) + { + foreach (var item in used.Items.Where(i => i.Type == UsedItemTypes.Text)) + { + var price = RoundPrice(item.UnitPrice * item.Quantity, false); + invoice.Lines.Add(new InvoiceLine { Text = item.Text, Quantity = item.Quantity, UnitPrice = item.UnitPrice, Price = price }); + invoice.Price += price; + } + + if (used.Items.Where(i => i.Type == UsedItemTypes.Hours).Count() > 0) + { + invoice.TimeItems = new List(); + decimal totalTimePrice = 0; + decimal totalTimeHours = 0; + foreach (var item in used.Items.Where(i => i.Type == UsedItemTypes.Hours)) + { + invoice.TimeItems.Add(item); + totalTimeHours += item.Quantity; + totalTimePrice += RoundPrice(item.UnitPrice * item.Quantity, true); + } + var price = RoundPrice(totalTimePrice, false); + var unitPrice = RoundPrice(totalTimePrice / totalTimeHours, true); + invoice.Lines.Add(new InvoiceLine { Text = "Time added together", Quantity = totalTimeHours, UnitPrice = unitPrice, Price = price }); + invoice.Price += price; + } + } + + if (includeVat) + { + invoice.Vat = RoundPrice(invoice.Price * settings.Usage.VatPercent / 100, false); + } + invoice.TotalPrice = invoice.Price + invoice.Vat; + } + + private (decimal planPrice, string planInfoText) MonthUsagePrice(Used used, Plan plan, decimal exchangeRate) + { + var invoiceDays = used.PeriodEndDate.Day - used.PeriodBeginDate.Day; + if (invoiceDays > 10) + { + if (used.PeriodBeginDate.Day == 1) + { + return (CurrencyAndRoundPrice(plan.CostPerMonth, exchangeRate, false), string.Empty); + } + else + { + var price = CurrencyAndRoundPrice(plan.CostPerMonth / DateTime.DaysInMonth(used.PeriodBeginDate.Year, used.PeriodBeginDate.Month) * invoiceDays, exchangeRate, false); + return (price, " (first month reduced price)"); + } + } + else + { + return (0, " (first short month free)"); + } + } + + private decimal AddInvoiceUsageLine(List lines, string textFirstLevel, string textSecondLevel, decimal usedCount, PlanItem planItem, decimal exchangeRate) + { + decimal price = 0; + + var firstLevel = planItem.FirstLevelThreshold > 0 && usedCount > planItem.FirstLevelThreshold ? Convert.ToDecimal(planItem.FirstLevelThreshold) : usedCount; + var firstLevelUnitPrice = CurrencyAndRoundPrice(planItem.FirstLevelCost, exchangeRate, true); + var firstLevelQuantity = usedCount > planItem.Included ? firstLevel - planItem.Included : 0; + var firstLevelPrice = RoundPrice(firstLevelUnitPrice * firstLevelQuantity, false); + lines.Add(new InvoiceLine { Text = textFirstLevel, Quantity = firstLevelQuantity, UnitPrice = firstLevelUnitPrice, Price = firstLevelPrice }); + price += firstLevelPrice; + + if (planItem.FirstLevelThreshold > 0 && usedCount > planItem.FirstLevelThreshold) + { + var secondLevelUnitPrice = CurrencyAndRoundPrice(planItem.SecondLevelCost.Value, exchangeRate, true); + var secundLevelPriceQuantity = usedCount - planItem.FirstLevelThreshold.Value; + var secundLevelPrice = RoundPrice(secondLevelUnitPrice * secundLevelPriceQuantity, false); + lines.Add(new InvoiceLine { Text = textSecondLevel, Quantity = secundLevelPriceQuantity, UnitPrice = secondLevelUnitPrice, Price = secundLevelPrice }); + price += secundLevelPrice; + } + + return price; + } + + private decimal CurrencyAndRoundPrice(decimal price, decimal exchangeRate, bool isUnitPrice) + { + return RoundPrice(price * exchangeRate, isUnitPrice); + } + + private decimal RoundPrice(decimal price, bool isUnitPrice) + { + return decimal.Round(price, isUnitPrice ? 6 : 2); + } + + private async Task CallExternalMakeInvoiceAsync(Used used, Invoice invoice, bool sendInvoice) + { + var invoiceRequest = mapper.Map(invoice); + invoiceRequest.SendInvoice = sendInvoice; + invoiceRequest.IsPaid = used.PaymentStatus == UsagePaymentStatus.Paid; + invoiceRequest.TenantName = used.TenantName; + invoiceRequest.PeriodBeginDate = used.PeriodBeginDate; + invoiceRequest.PeriodEndDate = used.PeriodEndDate; + await invoiceRequest.ValidateObjectAsync(); + + var httpClient = httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(IdentityConstants.BasicAuthentication.Basic, $"{settings.Usage.ExternalInvoiceApiId.OAuthUrlDencode()}:{settings.Usage.ExternalInvoiceApiSecret.OAuthUrlDencode()}".Base64Encode()); + var content = new StringContent(JsonConvert.SerializeObject(invoiceRequest, JsonSettings.ExternalSerializerSettings), Encoding.UTF8, MediaTypeNames.Application.Json); + using var response = await httpClient.PostAsync(settings.Usage.ExternalInvoiceApiUrl, content); + switch (response.StatusCode) + { + case HttpStatusCode.OK: + var result = await response.Content.ReadAsStringAsync(); + var invoiceResponse = result.ToObject(); + return invoiceResponse; + + default: + var resultUnexpectedStatus = await response.Content.ReadAsStringAsync(); + throw new Exception($"Send external invoice request, error '{resultUnexpectedStatus}'. Status code={response.StatusCode}."); + } + } + + private string EventNameText(bool isCardPayment) => isCardPayment ? "'card'" : "'payment period'"; + } +} diff --git a/src/FoxIDs.Control/Logic/Usage/UsageMolliePaymentLogic.cs b/src/FoxIDs.Control/Logic/Usage/UsageMolliePaymentLogic.cs new file mode 100644 index 000000000..a7be675d4 --- /dev/null +++ b/src/FoxIDs.Control/Logic/Usage/UsageMolliePaymentLogic.cs @@ -0,0 +1,166 @@ +using FoxIDs.Models; +using FoxIDs.Repository; +using ITfoxtec.Identity; +using Mollie.Api.Client.Abstract; +using Mollie.Api.Models; +using Mollie.Api.Models.Mandate.Response.PaymentSpecificParameters; +using Mollie.Api.Models.Payment.Request; +using Mollie.Api.Models.Payment; +using System; +using System.Threading.Tasks; +using FoxIDs.Infrastructure; + +namespace FoxIDs.Logic.Usage +{ + public class UsageMolliePaymentLogic + { + private readonly TelemetryScopedLogger logger; + private readonly ITenantDataRepository tenantDataRepository; + private readonly IMandateClient mandateClient; + private readonly IPaymentClient paymentClient; + + public UsageMolliePaymentLogic(TelemetryScopedLogger logger, ITenantDataRepository tenantDataRepository, IMandateClient mandateClient, IPaymentClient paymentClient) + { + this.logger = logger; + this.tenantDataRepository = tenantDataRepository; + this.mandateClient = mandateClient; + this.paymentClient = paymentClient; + } + + public bool HasCardPayment(Tenant tenant) + { + if (tenant.Payment != null && !string.IsNullOrEmpty(tenant.Payment.MandateId)) + { + return true; + } + return false; + } + + public bool HasActiveCardPayment(Tenant tenant) + { + if (HasCardPayment(tenant) && tenant.Payment.IsActive) + { + return true; + } + return false; + } + + public async Task UpdatePaymentMandate(Tenant tenant) + { + if (tenant.Payment != null && string.IsNullOrEmpty(tenant.Payment.CardNumberInfo) && !string.IsNullOrEmpty(tenant.Payment.MandateId)) + { + var mandateResponse = await mandateClient.GetMandateAsync(tenant.Payment.CustomerId, tenant.Payment.MandateId) as CreditCardMandateResponse; + if ("valid".Equals(mandateResponse.Status, StringComparison.OrdinalIgnoreCase)) + { + tenant.Payment.IsActive = true; + var cardExpiryDate = DateTime.Parse(mandateResponse.Details.CardExpiryDate); + tenant.Payment.CardHolder = mandateResponse.Details.CardHolder; + tenant.Payment.CardNumberInfo = mandateResponse.Details.CardNumber; + tenant.Payment.CardLabel = mandateResponse.Details.CardLabel; + tenant.Payment.CardExpiryMonth = cardExpiryDate.Month; + tenant.Payment.CardExpiryYear = cardExpiryDate.Year; + + await tenantDataRepository.UpdateAsync(tenant); + } + } + } + + public async Task RevokePaymentMandateAsync(Tenant tenant) + { + if (tenant.Payment != null) + { + await mandateClient.RevokeMandate(tenant.Payment.CustomerId, tenant.Payment.MandateId); + } + } + + + public async Task DoPaymentAsync(Tenant tenant, Used used, Invoice invoice) + { + if (!HasActiveCardPayment(tenant)) + { + throw new InvalidOperationException("Not an active payment."); + } + + try + { + logger.Event($"Usage, payment 'card' for tenant '{used.TenantName}' started."); + + var paymentRequest = new PaymentRequest + { + RedirectUrl = "https://www.foxids.com", + Amount = new Amount(invoice.Currency, invoice.TotalPrice), + Description = "FoxIDs subscription", + CustomerId = tenant.Payment.CustomerId, + SequenceType = SequenceType.Recurring, + MandateId = tenant.Payment.MandateId, + }; + + var paymentResponse = await paymentClient.CreatePaymentAsync(paymentRequest); + used.PaymentStatus = paymentResponse.Status.FromMollieStatusToPaymentStatus(); + used.PaymentId = paymentResponse.Id; + await tenantDataRepository.UpdateAsync(used); + + logger.Event($"Usage, payment 'card' for tenant '{used.TenantName}' status '{used.PaymentStatus}'."); + + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception ex) + { + try + { + used.PaymentStatus = UsagePaymentStatus.Failed; + await tenantDataRepository.UpdateAsync(used); + } + catch (Exception saveEx) + { + logger.Error(saveEx, $"Usage, unable to save status: {UsagePaymentStatus.Failed}."); + } + logger.Error(ex, $"Usage, payment 'card' for tenant '{used.TenantName}' error.."); + return false; + } + } + + public async Task UpdatePaymentAsync(Used used) + { + if (used.PaymentId.IsNullOrEmpty()) + { + throw new InvalidOperationException("The payment id is empty."); + } + + try + { + logger.Event($"Usage, read payment 'card' for tenant '{used.TenantName}' started."); + + var paymentResponse = await paymentClient.GetPaymentAsync(used.PaymentId); + used.PaymentStatus = paymentResponse.Status.FromMollieStatusToPaymentStatus(); + await tenantDataRepository.UpdateAsync(used); + + logger.Event($"Usage, read payment 'card' for tenant '{used.TenantName}' status '{used.PaymentStatus}'."); + + return true; + } + catch (Exception ex) + { + try + { + used.PaymentStatus = UsagePaymentStatus.Failed; + await tenantDataRepository.UpdateAsync(used); + } + catch (Exception saveEx) + { + logger.Error(saveEx, $"Usage, unable to save status: {UsagePaymentStatus.Failed}."); + } + logger.Error(ex, $"Usage, read payment 'card' for tenant '{used.TenantName}' error."); + return false; + } + } + } +} diff --git a/src/FoxIDs.Control/MappingProfiles/ExternalMappingProfile.cs b/src/FoxIDs.Control/MappingProfiles/ExternalMappingProfile.cs new file mode 100644 index 000000000..477f08044 --- /dev/null +++ b/src/FoxIDs.Control/MappingProfiles/ExternalMappingProfile.cs @@ -0,0 +1,23 @@ +using AutoMapper; +using FoxIDs.Models; +using ExtInv = FoxIDs.Models.ExternalInvoices; + +namespace FoxIDs.MappingProfiles +{ + public class ExternalMappingProfile : Profile + { + public ExternalMappingProfile() + { + Mapping(); + } + + private void Mapping() + { + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + } + } +} diff --git a/src/FoxIDs.Control/MappingProfiles/MasterMappingProfile.cs b/src/FoxIDs.Control/MappingProfiles/MasterMappingProfile.cs index 6671918a4..53795c5c7 100644 --- a/src/FoxIDs.Control/MappingProfiles/MasterMappingProfile.cs +++ b/src/FoxIDs.Control/MappingProfiles/MasterMappingProfile.cs @@ -22,19 +22,21 @@ private void Mapping() .ForMember(d => d.Id, opt => opt.MapFrom(s => Plan.IdFormatAsync(s.Name.ToLower()).GetAwaiter().GetResult())); CreateMap() .ReverseMap(); + CreateMap(); CreateMap() .ReverseMap(); CreateMap() .ReverseMap(); - CreateMap() - .ReverseMap(); - CreateMap() - .ReverseMap(); + CreateMap() .ForMember(d => d.PasswordSha1Hash, opt => opt.MapFrom(s => s.Id.Substring(s.Id.LastIndexOf(':') + 1))) .ReverseMap(); + + CreateMap(); + CreateMap() + .ReverseMap(); } } } diff --git a/src/FoxIDs.Control/MappingProfiles/TenantMappingProfiles.cs b/src/FoxIDs.Control/MappingProfiles/TenantMappingProfiles.cs index bfd55679d..d7b793d8d 100644 --- a/src/FoxIDs.Control/MappingProfiles/TenantMappingProfiles.cs +++ b/src/FoxIDs.Control/MappingProfiles/TenantMappingProfiles.cs @@ -1,6 +1,8 @@ -using AutoMapper; +using MSTokens = Microsoft.IdentityModel.Tokens; +using AutoMapper; using FoxIDs.Logic; using FoxIDs.Models; +using FoxIDs.Models.Config; using ITfoxtec.Identity; using ITfoxtec.Identity.Models; using Microsoft.AspNetCore.Http; @@ -34,6 +36,34 @@ private void Mapping() .ForMember(d => d.Name, opt => opt.MapFrom(s => s.Name.ToLower())) .ForMember(d => d.Id, opt => opt.MapFrom(s => Tenant.IdFormatAsync(s.Name.ToLower()).GetAwaiter().GetResult())); + CreateMap() + .ReverseMap() + .ForMember(d => d.Name, opt => opt.MapFrom(s => s.Name.ToLower())) + .ForMember(d => d.Id, opt => opt.MapFrom(s => Tenant.IdFormatAsync(s.Name.ToLower()).GetAwaiter().GetResult())); + + CreateMap() + .ReverseMap(); + CreateMap() + .ReverseMap(); + + CreateMap(); + + CreateMap(); + + CreateMap() + .ForMember(d => d.HasItems, opt => opt.MapFrom(s => s.Items != null && s.Items.Count() > 0)); + CreateMap() + .ForMember(d => d.HasItems, opt => opt.MapFrom(s => s.Items != null && s.Items.Count() > 0)); + CreateMap() + .ReverseMap() + .ForMember(d => d.Items, opt => opt.MapFrom(s => s.Items != null && s.Items.Any() ? s.Items.OrderBy(i => i.Day) : null)) + .ForMember(d => d.TenantName, opt => opt.MapFrom(s => s.TenantName.ToLower())) + .ForMember(d => d.Id, opt => opt.MapFrom(s => Used.IdFormatAsync(s.TenantName.ToLower(), s.PeriodBeginDate.Year, s.PeriodBeginDate.Month).GetAwaiter().GetResult())); + CreateMap() + .ReverseMap(); + CreateMap(); + CreateMap(); + CreateMap() .ReverseMap() .ForMember(d => d.Name, opt => opt.MapFrom(s => s.Name.ToLower())) @@ -335,16 +365,19 @@ private List OrderClaimTransforms(List claimTransforms) where T : Api.C private Api.CertificateInfo GetCertificateInfo(JsonWebKey jsonWebKey) { - var certificate = jsonWebKey.ToX509Certificate(); - if (certificate != null) + if (jsonWebKey.X5c?.Count() > 0) { - return new Api.CertificateInfo + var certificate = jsonWebKey.ToX509Certificate(); + if (certificate != null) { - Subject = certificate.Subject, - ValidFrom = certificate.NotBefore, - ValidTo = certificate.NotAfter, - Thumbprint = certificate.Thumbprint - }; + return new Api.CertificateInfo + { + Subject = certificate.Subject, + ValidFrom = certificate.NotBefore, + ValidTo = certificate.NotAfter, + Thumbprint = certificate.Thumbprint + }; + } } return null; } diff --git a/src/FoxIDs.Control/Models/Config/FoxIDsControlSettings.cs b/src/FoxIDs.Control/Models/Config/FoxIDsControlSettings.cs index 99d7f0ee1..02851a12d 100644 --- a/src/FoxIDs.Control/Models/Config/FoxIDsControlSettings.cs +++ b/src/FoxIDs.Control/Models/Config/FoxIDsControlSettings.cs @@ -34,6 +34,12 @@ public class FoxIDsControlSettings : Settings, IValidatableObject [Required] public int DownPartyTestLifetime { get; set; } = 900; // 15 minutes + [ValidateComplexType] + public PaymentSettings Payment { get; set; } + + [ValidateComplexType] + public UsageBaseSettings Usage { get; set; } + public override IEnumerable Validate(ValidationContext validationContext) { var results = base.Validate(validationContext).ToList(); diff --git a/src/FoxIDs.Control/Models/Config/PaymentSettings.cs b/src/FoxIDs.Control/Models/Config/PaymentSettings.cs new file mode 100644 index 000000000..f13cc34dc --- /dev/null +++ b/src/FoxIDs.Control/Models/Config/PaymentSettings.cs @@ -0,0 +1,12 @@ +namespace FoxIDs.Models.Config +{ + public class PaymentSettings + { + public bool TestMode { get; set; } = false; + public bool EnablePayment => !string.IsNullOrWhiteSpace(MollieApiKey) && !string.IsNullOrWhiteSpace(MollieProfileId); + + public string MollieApiKey { get; set; } + public string MollieProfileId { get; set; } + public string MollieApiUrl { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/Config/UsageBaseSettings.cs b/src/FoxIDs.Control/Models/Config/UsageBaseSettings.cs new file mode 100644 index 000000000..746c60607 --- /dev/null +++ b/src/FoxIDs.Control/Models/Config/UsageBaseSettings.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Models.Config +{ + public class UsageBaseSettings + { + /// + /// The usage calculator background service wait period in seconds. + /// + public int BackgroundServiceWaitPeriod { get; set; } = 14400; // 4 hours + + [Required] + public UsageSellerSettings Seller { get; set; } + + /// + /// Default VAR percent. + /// + public int VatPercent { get; set; } = 25; + + public bool EnableInvoice => !string.IsNullOrWhiteSpace(ExternalInvoiceApiId) && !string.IsNullOrWhiteSpace(ExternalInvoiceApiSecret) && !string.IsNullOrWhiteSpace(ExternalInvoiceApiUrl); + + public string ExternalInvoiceApiId { get; set; } = "external_invoice"; + + public string ExternalInvoiceApiSecret { get; set; } + + public string ExternalInvoiceApiUrl { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/Config/UsageSellerSettings.cs b/src/FoxIDs.Control/Models/Config/UsageSellerSettings.cs new file mode 100644 index 000000000..a806ca8b6 --- /dev/null +++ b/src/FoxIDs.Control/Models/Config/UsageSellerSettings.cs @@ -0,0 +1,48 @@ +using FoxIDs.Infrastructure.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Models.Config +{ + public class UsageSellerSettings + { + [Required] + [MaxLength(Constants.Models.User.EmailLength)] + [RegularExpression(Constants.Models.User.EmailRegExPattern)] + public string FromEmail { get; set; } + + [ListLength(Constants.Models.Seller.BccEmailsMin, Constants.Models.Seller.BccEmailsMax, Constants.Models.User.EmailLength, Constants.Models.User.EmailRegExPattern)] + public List BccEmails { get; set; } + + /// + /// Company name or name. + /// + [MaxLength(Constants.Models.Address.NameLength)] + public string Name { get; set; } + + [MaxLength(Constants.Models.Address.VatNumberLength)] + public string VatNumber { get; set; } + + [MaxLength(Constants.Models.Address.AddressLine1Length)] + public string AddressLine1 { get; set; } + + [MaxLength(Constants.Models.Address.AddressLine2Length)] + public string AddressLine2 { get; set; } + + [MaxLength(Constants.Models.Address.PostalCodeLength)] + public string PostalCode { get; set; } + + [MaxLength(Constants.Models.Address.CityLength)] + public string City { get; set; } + + [MaxLength(Constants.Models.Address.StateRegionLength)] + public string StateRegion { get; set; } + + [MaxLength(Constants.Models.Address.CountryLength)] + public string Country { get; set; } + + public int PaymentDueDays { get; set; } = 10; + + public List BankDetails { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/ExternalInvoices/Address.cs b/src/FoxIDs.Control/Models/ExternalInvoices/Address.cs new file mode 100644 index 000000000..db5c3de1f --- /dev/null +++ b/src/FoxIDs.Control/Models/ExternalInvoices/Address.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Models.ExternalInvoices +{ + public abstract class Address + { + /// + /// Company name or name. + /// + [Required] + [MaxLength(Constants.Models.Address.NameLength)] + public string Name { get; set; } + + [MaxLength(Constants.Models.Address.VatNumberLength)] + public string VatNumber { get; set; } + + [MaxLength(Constants.Models.Address.AddressLine1Length)] + public string AddressLine1 { get; set; } + + [MaxLength(Constants.Models.Address.AddressLine2Length)] + public string AddressLine2 { get; set; } + + [MaxLength(Constants.Models.Address.PostalCodeLength)] + public string PostalCode { get; set; } + + [MaxLength(Constants.Models.Address.CityLength)] + public string City { get; set; } + + [MaxLength(Constants.Models.Address.StateRegionLength)] + public string StateRegion { get; set; } + + [MaxLength(Constants.Models.Address.CountryLength)] + public string Country { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/ExternalInvoices/Customer.cs b/src/FoxIDs.Control/Models/ExternalInvoices/Customer.cs new file mode 100644 index 000000000..c9fed05d4 --- /dev/null +++ b/src/FoxIDs.Control/Models/ExternalInvoices/Customer.cs @@ -0,0 +1,15 @@ +using FoxIDs.Infrastructure.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Models.ExternalInvoices +{ + public class Customer : Address + { + [ListLength(Constants.Models.Customer.InvoiceEmailsMin, Constants.Models.Customer.InvoiceEmailsMax, Constants.Models.User.EmailLength, Constants.Models.User.EmailRegExPattern)] + public List InvoiceEmails { get; set; } + + [MaxLength(Constants.Models.Customer.ReferenceLength)] + public string Reference { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/ExternalInvoices/InvoiceLine.cs b/src/FoxIDs.Control/Models/ExternalInvoices/InvoiceLine.cs new file mode 100644 index 000000000..2861a4fe4 --- /dev/null +++ b/src/FoxIDs.Control/Models/ExternalInvoices/InvoiceLine.cs @@ -0,0 +1,20 @@ +using FoxIDs.Infrastructure.DataAnnotations; +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Models.ExternalInvoices +{ + public class InvoiceLine + { + [MaxLength(Constants.Models.Used.InvoiceLineTextLength)] + public string Text { get; set; } + + [Min(Constants.Models.Used.QuantityMin)] + public decimal Quantity { get; set; } + + [Min(Constants.Models.Used.PriceMin)] + public decimal UnitPrice { get; set; } + + [Min(Constants.Models.Used.PriceMin)] + public decimal Price { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/ExternalInvoices/InvoiceRequest.cs b/src/FoxIDs.Control/Models/ExternalInvoices/InvoiceRequest.cs new file mode 100644 index 000000000..efeec8f0c --- /dev/null +++ b/src/FoxIDs.Control/Models/ExternalInvoices/InvoiceRequest.cs @@ -0,0 +1,88 @@ +using FoxIDs.Infrastructure.DataAnnotations; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +namespace FoxIDs.Models.ExternalInvoices +{ + public class InvoiceRequest : IValidatableObject + { + public bool SendInvoice { get; set; } + + public bool IsCardPayment { get; set; } + + public bool IsPaid { get; set; } + + public bool IsCreditNote { get; set; } + + [Required] + public string InvoiceNumber { get; set; } + + [Required] + [MaxLength(Constants.Models.Tenant.NameLength)] + [RegularExpression(Constants.Models.Tenant.NameRegExPattern)] + public string TenantName { get; set; } + + [Required] + public DateOnly IssueDate { get; set; } + + public DateOnly? DueDate { get; set; } + + [Required] + public DateOnly PeriodBeginDate { get; set; } + + [Required] + public DateOnly PeriodEndDate { get; set; } + + [Required] + [MaxLength(Constants.Models.Currency.CurrencyLength)] + public string Currency { get; set; } + + public bool IncludesUsage { get; set; } + + [ListLength(Constants.Models.Used.InvoiceLinesMin, Constants.Models.Used.ItemsMax)] + public List Lines { get; set; } + + [Min(Constants.Models.Used.PriceMin)] + public decimal Price { get; set; } + + [Min(Constants.Models.Used.PriceMin)] + public decimal Vat { get; set; } + + [Min(Constants.Models.Used.PriceMin)] + public decimal TotalPrice { get; set; } + + /// + /// Time specification items, + /// + [ListLength(Constants.Models.Used.ItemsMin, Constants.Models.Used.ItemsMax)] + public List TimeItems { get; set; } + + [Required] + public Seller Seller { get; set; } + + [Required] + public Customer Customer { get; set; } + + public List BankDetails { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + var results = new List(); + if (PeriodBeginDate.Year != PeriodEndDate.Year || PeriodBeginDate.Month != PeriodEndDate.Month) + { + results.Add(new ValidationResult($"The {nameof(PeriodBeginDate)} and {nameof(PeriodEndDate)} need to be in the same year and month.", [nameof(PeriodBeginDate), nameof(PeriodEndDate)])); + } + + if (TimeItems?.Count() > 0) + { + if (TimeItems.Where(i => i.Type != UsedItemTypes.Hours).Any()) + { + results.Add(new ValidationResult($"Only {nameof(UsedItem)} with the {nameof(UsedItem.Type)} of '{UsedItemTypes.Hours}' is allowed in the {nameof(TimeItems)} field.", [nameof(TimeItems)])); + } + } + return results; + } + } +} diff --git a/src/FoxIDs.Control/Models/ExternalInvoices/InvoiceResponse.cs b/src/FoxIDs.Control/Models/ExternalInvoices/InvoiceResponse.cs new file mode 100644 index 000000000..de14ce15a --- /dev/null +++ b/src/FoxIDs.Control/Models/ExternalInvoices/InvoiceResponse.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Models.ExternalInvoices +{ + public class InvoiceResponse + { + [Required] + public string InvoiceNumber { get; set; } + + /// + /// Base64 encoded PDF invoice. + /// + public string PdfInvoice { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/ExternalInvoices/Seller.cs b/src/FoxIDs.Control/Models/ExternalInvoices/Seller.cs new file mode 100644 index 000000000..13952f153 --- /dev/null +++ b/src/FoxIDs.Control/Models/ExternalInvoices/Seller.cs @@ -0,0 +1,17 @@ +using FoxIDs.Infrastructure.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Models.ExternalInvoices +{ + public class Seller : Address + { + [Required] + [MaxLength(Constants.Models.User.EmailLength)] + [RegularExpression(Constants.Models.User.EmailRegExPattern)] + public string FromEmail { get; set; } + + [ListLength(Constants.Models.Seller.BccEmailsMin, Constants.Models.Seller.BccEmailsMax, Constants.Models.User.EmailLength, Constants.Models.User.EmailRegExPattern)] + public List BccEmails { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/ExternalInvoices/UsedItem.cs b/src/FoxIDs.Control/Models/ExternalInvoices/UsedItem.cs new file mode 100644 index 000000000..1ac9525bd --- /dev/null +++ b/src/FoxIDs.Control/Models/ExternalInvoices/UsedItem.cs @@ -0,0 +1,41 @@ +using FoxIDs.Infrastructure.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Models.ExternalInvoices +{ + public class UsedItem : IValidatableObject + { + [MaxLength(Constants.Models.Used.UsedItemTextLength)] + public string Text { get; set; } + + [Range(Constants.Models.Used.DayMin, Constants.Models.Used.DayMax)] + public int? Day { get; set; } + + [Min(Constants.Models.Used.QuantityMin)] + public decimal Quantity { get; set; } + + [Min(Constants.Models.Used.PriceMin)] + public decimal UnitPrice { get; set; } + + public UsedItemTypes Type { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + var results = new List(); + if (Quantity == 0) + { + results.Add(new ValidationResult($"The {nameof(Quantity)} field is required.", [nameof(Quantity), nameof(Type)])); + } + + if (Type == UsedItemTypes.Hours) + { + if (Day == 0) + { + results.Add(new ValidationResult($"The {nameof(Day)} field is required if the {nameof(Type)} field is '{Type}'.", [nameof(Day), nameof(Type)])); + } + } + return results; + } + } +} diff --git a/src/FoxIDs.Control/Models/ExternalInvoices/UsedItemTypes.cs b/src/FoxIDs.Control/Models/ExternalInvoices/UsedItemTypes.cs new file mode 100644 index 000000000..61cb92fe4 --- /dev/null +++ b/src/FoxIDs.Control/Models/ExternalInvoices/UsedItemTypes.cs @@ -0,0 +1,14 @@ +namespace FoxIDs.Models.ExternalInvoices +{ + public enum UsedItemTypes + { + /// + /// One item with a text. + /// + Text = 10, + /// + /// A number of hours. + /// + Hours = 100 + } +} diff --git a/src/FoxIDs.Control/Models/Payments/MolliePaymentDetailsResponse.cs b/src/FoxIDs.Control/Models/Payments/MolliePaymentDetailsResponse.cs new file mode 100644 index 000000000..bf2f7cc20 --- /dev/null +++ b/src/FoxIDs.Control/Models/Payments/MolliePaymentDetailsResponse.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace FoxIDs.Models.Payments +{ + public class MolliePaymentDetailsResponse + { + [JsonProperty(PropertyName = "failureReason")] + public string FailureReason { get; set; } + + [JsonProperty(PropertyName = "failureMessage")] + public string FailureMessage { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/Payments/MolliePaymentLinkResponse.cs b/src/FoxIDs.Control/Models/Payments/MolliePaymentLinkResponse.cs new file mode 100644 index 000000000..7af8ed792 --- /dev/null +++ b/src/FoxIDs.Control/Models/Payments/MolliePaymentLinkResponse.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace FoxIDs.Models.Payments +{ + public class MolliePaymentLinkResponse + { + [JsonProperty(PropertyName = "ref")] + public string Href { get; set; } + + [JsonProperty(PropertyName = "type")] + public string Type { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/Payments/MolliePaymentLinksResponse.cs b/src/FoxIDs.Control/Models/Payments/MolliePaymentLinksResponse.cs new file mode 100644 index 000000000..2abe0a670 --- /dev/null +++ b/src/FoxIDs.Control/Models/Payments/MolliePaymentLinksResponse.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace FoxIDs.Models.Payments +{ + public class MolliePaymentLinksResponse + { + [JsonProperty(PropertyName = "self")] + public string Self { get; set; } + + [JsonProperty(PropertyName = "checkout")] + public string Checkout { get; set; } + + [JsonProperty(PropertyName = "documentation")] + public string Documentation { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/Payments/MolliePaymentResponse.cs b/src/FoxIDs.Control/Models/Payments/MolliePaymentResponse.cs new file mode 100644 index 000000000..6ece2cc5f --- /dev/null +++ b/src/FoxIDs.Control/Models/Payments/MolliePaymentResponse.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; +using System; + +namespace FoxIDs.Models.Payments +{ + public class MolliePaymentResponse + { + [JsonProperty(PropertyName = "id")] + public string Id { get; set; } + + [JsonProperty(PropertyName = "mode")] + public string Mode { get; set; } + + [JsonProperty(PropertyName = "createdAt")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonProperty(PropertyName = "expiresAt")] + public DateTimeOffset ExpiresAt { get; set; } + + [JsonProperty(PropertyName = "status")] + public string Status { get; set; } + + [JsonProperty(PropertyName = "details")] + public MolliePaymentDetailsResponse Details { get; set; } + + [JsonProperty(PropertyName = "_links")] + public MolliePaymentLinksResponse Links { get; set; } + } +} diff --git a/src/FoxIDs.Control/Models/Queue/UpPartyHrdQueueMessage.cs b/src/FoxIDs.Control/Models/Queues/UpPartyHrdQueueMessage.cs similarity index 98% rename from src/FoxIDs.Control/Models/Queue/UpPartyHrdQueueMessage.cs rename to src/FoxIDs.Control/Models/Queues/UpPartyHrdQueueMessage.cs index ea47a3421..6d21fd2a5 100644 --- a/src/FoxIDs.Control/Models/Queue/UpPartyHrdQueueMessage.cs +++ b/src/FoxIDs.Control/Models/Queues/UpPartyHrdQueueMessage.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -namespace FoxIDs.Models.Queue +namespace FoxIDs.Models.Queues { public class UpPartyHrdQueueMessage { diff --git a/src/FoxIDs.Control/Models/Queue/UpPartyHrdQueueMessageActions.cs b/src/FoxIDs.Control/Models/Queues/UpPartyHrdQueueMessageActions.cs similarity index 87% rename from src/FoxIDs.Control/Models/Queue/UpPartyHrdQueueMessageActions.cs rename to src/FoxIDs.Control/Models/Queues/UpPartyHrdQueueMessageActions.cs index 54f73b745..76cb5e3e7 100644 --- a/src/FoxIDs.Control/Models/Queue/UpPartyHrdQueueMessageActions.cs +++ b/src/FoxIDs.Control/Models/Queues/UpPartyHrdQueueMessageActions.cs @@ -1,4 +1,4 @@ -namespace FoxIDs.Models.Queue +namespace FoxIDs.Models.Queues { public enum UpPartyHrdQueueMessageActions { diff --git a/src/FoxIDs.ControlClient/FoxIDs.ControlClient.csproj b/src/FoxIDs.ControlClient/FoxIDs.ControlClient.csproj index 8fffb232b..57c129feb 100644 --- a/src/FoxIDs.ControlClient/FoxIDs.ControlClient.csproj +++ b/src/FoxIDs.ControlClient/FoxIDs.ControlClient.csproj @@ -2,7 +2,7 @@ net8.0 - 1.10.16 + 1.11.21 FoxIDs.Client Anders Revsgaard ITfoxtec @@ -12,7 +12,7 @@ - + diff --git a/src/FoxIDs.ControlClient/Logic/ControlClientSettingLogic.cs b/src/FoxIDs.ControlClient/Logic/ControlClientSettingLogic.cs index 456f99a0a..a6a717e46 100644 --- a/src/FoxIDs.ControlClient/Logic/ControlClientSettingLogic.cs +++ b/src/FoxIDs.ControlClient/Logic/ControlClientSettingLogic.cs @@ -9,11 +9,13 @@ public class ControlClientSettingLogic { private readonly ClientSettings clientSettings; private readonly ClientService clientService; + private readonly NotificationLogic notificationLogic; - public ControlClientSettingLogic(ClientSettings clientSettings, ClientService clientService) + public ControlClientSettingLogic(ClientSettings clientSettings, ClientService clientService, NotificationLogic notificationLogic) { this.clientSettings = clientSettings; this.clientService = clientService; + this.notificationLogic = notificationLogic; } public async Task InitLoadAsync() @@ -26,6 +28,11 @@ public async Task InitLoadAsync() clientSettings.FullVersion = controlClientSettings.FullVersion; clientSettings.LogOption = controlClientSettings.LogOption; clientSettings.KeyStorageOption = controlClientSettings.KeyStorageOption; + clientSettings.EnablePayment = controlClientSettings.EnablePayment; + clientSettings.PaymentTestMode = controlClientSettings.PaymentTestMode; + clientSettings.MollieProfileId = controlClientSettings.MollieProfileId; + + notificationLogic.ClientSettingLoaded(); } } } diff --git a/src/FoxIDs.ControlClient/Logic/NotificationLogic.cs b/src/FoxIDs.ControlClient/Logic/NotificationLogic.cs index d2f3a49b1..f254d0d79 100644 --- a/src/FoxIDs.ControlClient/Logic/NotificationLogic.cs +++ b/src/FoxIDs.ControlClient/Logic/NotificationLogic.cs @@ -5,6 +5,15 @@ namespace FoxIDs.Client.Logic { public class NotificationLogic { + public void ClientSettingLoaded() + { + if (OnClientSettingLoaded != null) + { + OnClientSettingLoaded(); + } + } + public event Action OnClientSettingLoaded; + public async Task TenantUpdatedAsync() { if(OnTenantUpdatedAsync != null) @@ -12,7 +21,24 @@ public async Task TenantUpdatedAsync() await OnTenantUpdatedAsync(); } } - public event Func OnTenantUpdatedAsync; + + public async Task OpenPaymentMethodAsync() + { + if(OnOpenPaymentMethodAsync != null) + { + await OnOpenPaymentMethodAsync(); + } + } + public event Func OnOpenPaymentMethodAsync; + + public void RequestPaymentUpdated() + { + if (OnRequestPaymentUpdated != null) + { + OnRequestPaymentUpdated(); + } + } + public event Action OnRequestPaymentUpdated; } } diff --git a/src/FoxIDs.ControlClient/Logic/RouteBindingLogic.cs b/src/FoxIDs.ControlClient/Logic/RouteBindingLogic.cs index d75adc2b8..5beb79fcb 100644 --- a/src/FoxIDs.ControlClient/Logic/RouteBindingLogic.cs +++ b/src/FoxIDs.ControlClient/Logic/RouteBindingLogic.cs @@ -17,20 +17,22 @@ public class RouteBindingLogic { private const string tenanSessionKey = "tenant_session"; private string tenantName; - private Tenant myTenant; + private TenantResponse myTenant; private bool? isMasterTenant; private readonly ClientSettings clientSettings; private readonly IServiceProvider serviceProvider; private readonly TrackSelectedLogic trackSelectedLogic; + private readonly NotificationLogic notificationLogic; private readonly NavigationManager navigationManager; private readonly ISessionStorageService sessionStorage; private readonly AuthenticationStateProvider authenticationStateProvider; - public RouteBindingLogic(ClientSettings clientSettings, IServiceProvider serviceProvider, TrackSelectedLogic trackSelectedLogic, NavigationManager navigationManager, ISessionStorageService sessionStorage, AuthenticationStateProvider authenticationStateProvider) + public RouteBindingLogic(ClientSettings clientSettings, IServiceProvider serviceProvider, TrackSelectedLogic trackSelectedLogic, NotificationLogic notificationLogic, NavigationManager navigationManager, ISessionStorageService sessionStorage, AuthenticationStateProvider authenticationStateProvider) { this.clientSettings = clientSettings; this.serviceProvider = serviceProvider; this.trackSelectedLogic = trackSelectedLogic; + this.notificationLogic = notificationLogic; this.navigationManager = navigationManager; this.sessionStorage = sessionStorage; this.authenticationStateProvider = authenticationStateProvider; @@ -38,11 +40,43 @@ public RouteBindingLogic(ClientSettings clientSettings, IServiceProvider service public bool IsMasterTenant => (isMasterTenant ?? (isMasterTenant = Constants.Routes.MasterTenantName.Equals(tenantName, StringComparison.OrdinalIgnoreCase))).Value; - private bool IsMasterTrack => trackSelectedLogic.Track != null && Constants.Routes.MasterTrackName.Equals(trackSelectedLogic.Track.Name, StringComparison.OrdinalIgnoreCase); + public bool IsMasterTrack => trackSelectedLogic.Track != null && Constants.Routes.MasterTrackName.Equals(trackSelectedLogic.Track.Name, StringComparison.OrdinalIgnoreCase); - public void SetMyTenant(Tenant tenant) + public bool RequestPayment { get; private set; } + + public async Task SetMyTenantAsync(TenantResponse tenant) { myTenant = tenant; + + if (!IsMasterTenant && clientSettings.EnablePayment) + { + await UpdatRequestPaymentAsync(myTenant); + } + } + + private async Task UpdatRequestPaymentAsync(TenantResponse myTenant) + { + if (myTenant.EnableUsage && myTenant.DoPayment && !myTenant.PlanName.IsNullOrEmpty() && "free" != myTenant.PlanName && myTenant.Payment?.IsActive != true) + { + var helpersService = serviceProvider.GetService(); + var planInfoList = await helpersService.GetPlanInfoAsync(); + + decimal planCost = planInfoList.Where(p => p.Name == myTenant.PlanName).Select(p => p.CostPerMonth).FirstOrDefault(); + if (planCost > 0) + { + RequestPayment = true; + } + else + { + RequestPayment = false; + } + } + else + { + RequestPayment = false; + } + + notificationLogic.RequestPaymentUpdated(); } public async Task GetTenantNameAsync() @@ -118,7 +152,7 @@ private async Task LoadMyTenantAsync() if (authenticationState.User.Identity.IsAuthenticated && !IsMasterTenant) { var myTenantService = serviceProvider.GetService(); - myTenant = await myTenantService.GetTenantAsync(); + await SetMyTenantAsync(await myTenantService.GetTenantAsync()); } } } diff --git a/src/FoxIDs.ControlClient/Models/Config/ClientSettings.cs b/src/FoxIDs.ControlClient/Models/Config/ClientSettings.cs index 3ffcc88fb..0e3470ab4 100644 --- a/src/FoxIDs.ControlClient/Models/Config/ClientSettings.cs +++ b/src/FoxIDs.ControlClient/Models/Config/ClientSettings.cs @@ -8,11 +8,15 @@ public class ClientSettings public string Authority { get; set; } public string LoginCallBackPath { get; set; } public string LogoutCallBackPath { get; set; } + public string Version { get; set; } public string FullVersion { get; set; } public LogOptions LogOption { get; set; } public KeyStorageOptions KeyStorageOption { get; set; } + public bool EnablePayment { get; set; } + public bool PaymentTestMode { get; set; } + public string MollieProfileId { get; set; } } } diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Master/GeneralPlanViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Master/GeneralPlanViewModel.cs index 85c4e8094..6276cae56 100644 --- a/src/FoxIDs.ControlClient/Models/ViewModels/Master/GeneralPlanViewModel.cs +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Master/GeneralPlanViewModel.cs @@ -11,6 +11,7 @@ public GeneralPlanViewModel() public GeneralPlanViewModel(Plan plan) { Name = plan.Name; + DisplayName = plan.DisplayName; } public bool Edit { get; set; } diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Master/PlanViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Master/PlanViewModel.cs index e8d0bffc4..aef3d079c 100644 --- a/src/FoxIDs.ControlClient/Models/ViewModels/Master/PlanViewModel.cs +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Master/PlanViewModel.cs @@ -9,30 +9,26 @@ public class PlanViewModel [Required] [MaxLength(Constants.Models.Plan.NameLength)] [RegularExpression(Constants.Models.Plan.NameRegExPattern)] - [Display(Name = "Plan name")] + [Display(Name = "Technical plan name")] public string Name { get; set; } + [MaxLength(Constants.Models.Plan.DisplayNameLength)] + [RegularExpression(Constants.Models.Plan.DisplayNameRegExPattern)] + [Display(Name = "Plan name")] + public string DisplayName { get; set; } + [MaxLength(Constants.Models.Plan.TextLength)] [Display(Name = "Text")] public string Text { get; set; } - [Required] - [MaxLength(Constants.Models.Plan.CurrencyLength)] - [RegularExpression(Constants.Models.Plan.CurrencyRegExPattern)] - [Display(Name = "Currency")] - public string Currency { get; set; } - [Required] [Min(Constants.Models.Plan.CostPerMonthMin)] - [Display(Name = "Cost per month")] + [Display(Name = "Cost per month in EUR")] public decimal CostPerMonth { get; set; } [Display(Name = "Custom domain")] public bool EnableCustomDomain { get; set; } - [Display(Name = "Key Vault")] - public bool EnableKeyVault { get; set; } - [Required] [Display(Name = "Total tracks")] public PlanItem Tracks { get; set; } = new PlanItem(); diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Parties/IUpPartyHrd.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Parties/IUpPartyHrd.cs index 6f2a8b108..35d1ed254 100644 --- a/src/FoxIDs.ControlClient/Models/ViewModels/Parties/IUpPartyHrd.cs +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Parties/IUpPartyHrd.cs @@ -5,7 +5,7 @@ namespace FoxIDs.Client.Models.ViewModels { public interface IUpPartyHrd { - [Display(Name = "HRD Domains (use * to accept all domains)")] + [Display(Name = "HRD Domains (use * to accept all domains not configured on another authentication method)")] public List HrdDomains { get; set; } [Display(Name = "Show HRD button together with HRD domain")] diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Parties/OidcDownPartyViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Parties/OidcDownPartyViewModel.cs index 5c267f602..56b4662b9 100644 --- a/src/FoxIDs.ControlClient/Models/ViewModels/Parties/OidcDownPartyViewModel.cs +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Parties/OidcDownPartyViewModel.cs @@ -77,6 +77,12 @@ public class OidcDownPartyViewModel : IValidatableObject, IDownPartyName, IAllow /// public long TestExpireAt { get; set; } + /// + /// Test expiration in seconds. + /// + [Display(Name = "Expiration time in seconds (0 to disable)")] + public int TestExpireInSeconds { get; set; } = 900; + public IEnumerable Validate(ValidationContext validationContext) { var results = new List(); diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/CreateTenantViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/CreateTenantViewModel.cs index d74fda3ce..f5f807d5c 100644 --- a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/CreateTenantViewModel.cs +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/CreateTenantViewModel.cs @@ -44,12 +44,7 @@ public class CreateTenantViewModel [Display(Name = "Confirm administrator account")] public bool ConfirmAdministratorAccount { get; set; } = true; - /// - /// Plan (optional). - /// - [MaxLength(Constants.Models.Plan.NameLength)] - [RegularExpression(Constants.Models.Plan.NameRegExPattern)] - [Display(Name = "Plan (optional)")] + [Display(Name = "Plan")] public string PlanName { get; set; } } } diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/CustomerViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/CustomerViewModel.cs new file mode 100644 index 000000000..418f3526c --- /dev/null +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/CustomerViewModel.cs @@ -0,0 +1,52 @@ +using FoxIDs.Infrastructure.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Client.Models.ViewModels +{ + public class CustomerViewModel + { + [ListLength(0, Constants.Models.Customer.InvoiceEmailsMax, Constants.Models.User.EmailLength, Constants.Models.User.EmailRegExPattern)] + [Display(Name = "Invoice emails")] + public List InvoiceEmails { get; set; } + + [MaxLength(Constants.Models.Customer.ReferenceLength)] + [Display(Name = "Your reference")] + public string Reference { get; set; } + + /// + /// Company name or name. + /// + [MaxLength(Constants.Models.Address.NameLength)] + [Display(Name = "Company name / Name")] + public string Name { get; set; } + + [MaxLength(Constants.Models.Address.VatNumberLength)] + [Display(Name = "VAT number")] + public string VatNumber { get; set; } + + [MaxLength(Constants.Models.Address.AddressLine1Length)] + [Display(Name = "Address line 1")] + public string AddressLine1 { get; set; } + + [MaxLength(Constants.Models.Address.AddressLine2Length)] + [Display(Name = "Address line 2")] + public string AddressLine2 { get; set; } + + [MaxLength(Constants.Models.Address.PostalCodeLength)] + [Display(Name = "Postal code")] + public string PostalCode { get; set; } + + [MaxLength(Constants.Models.Address.CityLength)] + [Display(Name = "City")] + public string City { get; set; } + + [MaxLength(Constants.Models.Address.StateRegionLength)] + [Display(Name = "State / Region")] + public string StateRegion { get; set; } + + [MaxLength(Constants.Models.Address.CountryLength)] + [Display(Name = "Country")] + public string Country { get; set; } + } +} diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/FilterUsageViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/FilterUsageViewModel.cs new file mode 100644 index 000000000..866974e93 --- /dev/null +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/FilterUsageViewModel.cs @@ -0,0 +1,25 @@ +using FoxIDs.Infrastructure.DataAnnotations; +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Client.Models.ViewModels +{ + public class FilterUsageViewModel + { + [Required] + [Min(Constants.Models.Used.PeriodYearMin)] + [Display(Name = "Year")] + public int PeriodYear { get; set; } + + [Required] + [Range(Constants.Models.Used.PeriodMonthMin, Constants.Models.Used.PeriodMonthMax)] + [Display(Name = "Month")] + public int PeriodMonth { get; set; } + + /// + /// Search by tenant name. + /// + [MaxLength(Constants.Models.Tenant.CustomDomainLength)] + [Display(Name = "Search tenant")] + public string FilterTenantValue { get; set; } + } +} diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralTenantViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralTenantViewModel.cs index b4eb881c3..e935fe592 100644 --- a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralTenantViewModel.cs +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralTenantViewModel.cs @@ -3,7 +3,7 @@ namespace FoxIDs.Client.Models.ViewModels { - public class GeneralTenantViewModel : Tenant + public class GeneralTenantViewModel : TenantViewModel { public GeneralTenantViewModel() { } @@ -15,6 +15,8 @@ public GeneralTenantViewModel(Tenant tenant) CustomDomainVerified = tenant.CustomDomainVerified; } + public bool CreateMode { get; set; } + public bool Edit { get; set; } public bool ShowAdvanced { get; set; } @@ -25,6 +27,6 @@ public GeneralTenantViewModel(Tenant tenant) public string LoginUri { get; set; } - public PageEditForm Form { get; set; } + public PageEditForm Form { get; set; } } } diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralUsageSettingsViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralUsageSettingsViewModel.cs new file mode 100644 index 000000000..086a76033 --- /dev/null +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralUsageSettingsViewModel.cs @@ -0,0 +1,14 @@ +using FoxIDs.Client.Shared.Components; + +namespace FoxIDs.Client.Models.ViewModels +{ + public class GeneralUsageSettingsViewModel + { + public GeneralUsageSettingsViewModel() + { } + + public string Error { get; set; } + + public PageEditForm Form { get; set; } + } +} diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralUsedViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralUsedViewModel.cs new file mode 100644 index 000000000..df371a501 --- /dev/null +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/GeneralUsedViewModel.cs @@ -0,0 +1,40 @@ +using FoxIDs.Client.Shared.Components; +using FoxIDs.Models.Api; + +namespace FoxIDs.Client.Models.ViewModels +{ + public class GeneralUsedViewModel : UsedViewModel + { + public GeneralUsedViewModel() + { } + + public GeneralUsedViewModel(UsedBase used) + { + TenantName = used.TenantName; + PeriodBeginDate = used.PeriodBeginDate; + PeriodEndDate = used.PeriodEndDate; + IsUsageCalculated = used.IsUsageCalculated; + IsInvoiceReady = used.IsInvoiceReady; + PaymentStatus = used.PaymentStatus; + IsDone = used.IsDone; + HasItems = used.HasItems; + Invoices = used.Invoices; + } + + public bool Edit { get; set; } + + public bool ShowAdvanced { get; set; } + + public bool CreateMode { get; set; } + + public bool DeleteAcknowledge { get; set; } + + public bool InvoicingActionButtonDisabled { get; set; } + + public string Error { get; set; } + + public decimal HourPrice { get; set; } + + public PageEditForm Form { get; set; } + } +} diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/TenantSettingsViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/MasterTenantViewModel.cs similarity index 64% rename from src/FoxIDs.ControlClient/Models/ViewModels/Tenants/TenantSettingsViewModel.cs rename to src/FoxIDs.ControlClient/Models/ViewModels/Tenants/MasterTenantViewModel.cs index a46deaa96..d6c01ac17 100644 --- a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/TenantSettingsViewModel.cs +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/MasterTenantViewModel.cs @@ -1,13 +1,14 @@ -using System.ComponentModel.DataAnnotations; +using FoxIDs.Models.Api; +using System.ComponentModel.DataAnnotations; namespace FoxIDs.Client.Models.ViewModels { - public class TenantSettingsViewModel + public class MasterTenantViewModel { - [Display(Name = "Name")] + [Display(Name = "Tenant name")] public string Name { get; set; } - [Display(Name = "Plan name")] + [Display(Name = "Plan")] public string PlanName { get; set; } [MaxLength(Constants.Models.Tenant.CustomDomainLength)] @@ -17,5 +18,10 @@ public class TenantSettingsViewModel [Display(Name = "Custom domain is verified (read only)")] public bool CustomDomainVerified { get; set; } + + [ValidateComplexType] + public Customer Customer { get; set; } + + public Payment Payment { get; set; } } } diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/MolliePaymentErrorResult.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/MolliePaymentErrorResult.cs new file mode 100644 index 000000000..557924426 --- /dev/null +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/MolliePaymentErrorResult.cs @@ -0,0 +1,9 @@ +namespace FoxIDs.Client.Models.ViewModels +{ + public class MolliePaymentErrorResult + { + public object Message { get; set; } + + public string Detail { get; set; } + } +} diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/MolliePaymentResult.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/MolliePaymentResult.cs new file mode 100644 index 000000000..ed3f3c400 --- /dev/null +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/MolliePaymentResult.cs @@ -0,0 +1,10 @@ +namespace FoxIDs.Client.Models.ViewModels +{ + public class MolliePaymentResult + { + public string Token { get; set; } + + public MolliePaymentErrorResult Error { get; set; } + + } +} diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/TenantViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/TenantViewModel.cs new file mode 100644 index 000000000..1d8cc40bf --- /dev/null +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/TenantViewModel.cs @@ -0,0 +1,48 @@ +using FoxIDs.Infrastructure.DataAnnotations; +using FoxIDs.Models.Api; +using System.ComponentModel.DataAnnotations; + +namespace FoxIDs.Client.Models.ViewModels +{ + public class TenantViewModel + { + [Display(Name = "Tenant name")] + public string Name { get; set; } + + [Display(Name = "Plan")] + public string PlanName { get; set; } + + [MaxLength(Constants.Models.Tenant.CustomDomainLength)] + [RegularExpression(Constants.Models.Tenant.CustomDomainRegExPattern, ErrorMessage = "The field {0} must be a valid domain.")] + [Display(Name = "Custom domain")] + public string CustomDomain { get; set; } + + [Display(Name = "Custom domain is verified")] + public bool CustomDomainVerified { get; set; } + + [Display(Name = "Enable usage")] + public bool EnableUsage { get; set; } + + [Display(Name = "Do payment")] + public bool DoPayment { get; set; } + + /// + /// Default EUR if empty. + /// + [MaxLength(Constants.Models.Currency.CurrencyLength)] + [Display(Name = "Currency")] + public string Currency { get; set; } + + [Display(Name = "Include VAT")] + public bool IncludeVat { get; set; } + + [Min(Constants.Models.UsageSettings.HourPriceMin)] + [Display(Name = "Hour price")] + public decimal? HourPrice { get; set; } + + [ValidateComplexType] + public CustomerViewModel Customer { get; set; } + + public Payment Payment { get; set; } + } +} diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/UsageSettingsViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/UsageSettingsViewModel.cs new file mode 100644 index 000000000..3d37643f2 --- /dev/null +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/UsageSettingsViewModel.cs @@ -0,0 +1,13 @@ +using FoxIDs.Models.Api; +using System.Collections.Generic; + +namespace FoxIDs.Client.Models.ViewModels +{ + public class UsageSettingsViewModel : UsageSettings + { + public UsageSettingsViewModel() + { + CurrencyExchanges = new List(); + } + } +} diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/UsedViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/UsedViewModel.cs new file mode 100644 index 000000000..7929bd5de --- /dev/null +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tenants/UsedViewModel.cs @@ -0,0 +1,13 @@ +using FoxIDs.Models.Api; +using System.Collections.Generic; + +namespace FoxIDs.Client.Models.ViewModels +{ + public class UsedViewModel : Used + { + public UsedViewModel() + { + Items = new List(); + } + } +} diff --git a/src/FoxIDs.ControlClient/Models/ViewModels/Tracks/GeneralLogSettingsViewModel.cs b/src/FoxIDs.ControlClient/Models/ViewModels/Tracks/GeneralLogSettingsViewModel.cs index 89069adfb..5a3c65f0f 100644 --- a/src/FoxIDs.ControlClient/Models/ViewModels/Tracks/GeneralLogSettingsViewModel.cs +++ b/src/FoxIDs.ControlClient/Models/ViewModels/Tracks/GeneralLogSettingsViewModel.cs @@ -8,8 +8,6 @@ public class GeneralLogSettingsViewModel public GeneralLogSettingsViewModel() { } - public bool Edit { get; set; } - public string Error { get; set; } public PageEditForm Form { get; set; } diff --git a/src/FoxIDs.ControlClient/Pages/Certificates.razor b/src/FoxIDs.ControlClient/Pages/Certificates.razor index 02618f28f..975954107 100644 --- a/src/FoxIDs.ControlClient/Pages/Certificates.razor +++ b/src/FoxIDs.ControlClient/Pages/Certificates.razor @@ -1,149 +1,190 @@ @page "/{tenantName}/certificates" @inherits PageBase -
-
- The primary certificate is the environments and thus the Identity Provider's unique certificate. The are two different certificate container types to choose from. -
-
-
- @if (trackKey?.Type == TrackKeyTypes.Contained && certificates.Any(c => !c.IsPrimary && !c.CreateMode)) - { - - } - else +
+
+
+ The certificate is the environments and thus the Identity Provider's unique certificate. The are two different certificate container types to choose from. +
+ +
+
+
+ +
+
+ @if (trackKey?.Type == TrackKeyTypes.Contained) { - + @if (certificates.Any(c => !c.IsPrimary && !c.CreateMode)) + { +
+
+ +
+
+ } + else + { +
+
+ +
+
+ } } - - +
+
+ @if (trackKey?.Type == TrackKeyTypes.Contained) + { + @if (certificates.Any(c => !c.IsPrimary && !c.CreateMode)) + { + + } + else + { + + } + } +
-
-
- @if (!certificateLoadError.IsNullOrWhiteSpace()) - { - - } +
+ @if (!certificateLoadError.IsNullOrWhiteSpace()) + { + + } - @if (trackKey?.Type == TrackKeyTypes.Contained) - { - @foreach (var certificate in certificates.OrderByDescending(c => c.IsPrimary)) + @if (trackKey?.Type == TrackKeyTypes.Contained) { -

@(certificate.IsPrimary ? "Primary" : "Secondary") certificate

-
-
- @if (!certificate.Error.IsNullOrWhiteSpace()) - { - - } - @if (certificate.Edit) - { - -