Hi, I need to create a self-service upgrade/downgrade edition page. I need help understanding how this works, since I couldn't find any publicly available documentation on this. I presume there is already a system in place to accommodate this, since when upgrading, the charge would need to be prorated. But I'm guessing that these systems are probably tied in to manual edition changing by an admin.
In my self-service registration page, I use _subscriptionAppService.CreateSubscriptionAsync() to create the payment request and then direct the user to the Stripe page to process the payment and start the subscription. I also already have the source code from the StripePaymentGateway so I can make modifications there if necessary to get this working.
I wanted to see if there was a streamlined way of doing this. I could probably find a way to do it by directly modifying the payment gateway code and directly interface with Stripe, but my concern is that I may inadvertently bypass something in the Payment module which could cause additional problems, and I am also concerned I could miss something and cause an issue with the charge amount, subscription renewal date (since that is abstracted and I cannot access it afaik), etc.
If there is some endpoint I could access that would help with this, that would be greatly appreciated.
For now, I am looking to pass along the email entered during registration to the Stripe payment page. I am using a custom registration page right now, and when the user submits, it runs this code in OnPostAsync():
StartSubscriptionResultDto resultDto = await _multiTenancyAppService.RegisterAndSubscribeAsync(new SaasTenantCreateDto
{
Name = Input.TenantName,
AdminEmailAddress = Input.Email,
AdminPassword = Input.Password,
EditionId = Input.EditionId,
ActivationState = Volo.Saas.TenantActivationState.Passive
});
// If no payment required, redirect to login
if (resultDto.PaymentRequestId == Guid.Empty)
{
return LocalRedirect("/Account/Login");
}
// Redirect to payment
return LocalRedirectPreserveMethod("/Payment/GatewaySelection?paymentRequestId=" + resultDto.PaymentRequestId);
(MultiTenancyAppService is my appservice, but it calls _subscriptionAppService.CreateSubscriptionAsync()). This works fine, but I don't see almost any customization options for the stripe checkout. In Stripe's API, they allow you to pass an email (along with numerous other parameters) to the checkout to prefill fields. However, I don't see a way to do that with this workflow.
Am I missing some setting or configuration in this workflow? Is there a better workflow I could use that would give me more customization options? I am only using Stripe for subscriptions, so perhaps there is a way to bypass the GatewaySelection and go straight to Stripe along with some additional customizations?
For context, I have a page where my users can add users to their tenant account. I am calling the IdentityUserAppService methods to do this. This is working fine. I am also authorizing the whole app and then whitelisting certain paths and pages in the WebModule to force users to login if they are not:
Configure<RazorPagesOptions>(options =>
{
options.Conventions.AuthorizeFolder("/");
options.Conventions.AllowAnonymousToAreaPage("Account", "/Login");
options.Conventions.AllowAnonymousToAreaPage("Account", "/Register");
options.Conventions.AllowAnonymousToAreaPage("Account", "/ForgotPassword");
options.Conventions.AllowAnonymousToAreaPage("Account", "/ResetPassword");
options.Conventions.AllowAnonymousToAreaPage("Account", "/EmailConfirmation");
options.Conventions.AllowAnonymousToAreaPage("Account", "/TwoFactor");
options.Conventions.AllowAnonymousToFolder("/Account");
options.Conventions.AllowAnonymousToFolder("/Payment");
options.Conventions.AllowAnonymousToFolder("/Payment/Stripe");
options.Conventions.AllowAnonymousToFolder("/Public");
options.Conventions.AllowAnonymousToPage("/Error");
options.Conventions.AllowAnonymousToPage("/PrivacyPolicy");
options.Conventions.AllowAnonymousToPage("/Payment/GatewaySelection");
options.Conventions.AllowAnonymousToPage("/Payment/Stripe/PrePayment");
options.Conventions.AllowAnonymousToPage("/Payment/Stripe/PostPayment");
});
My issue is that when the user selects ShouldChangePasswordOnNextLogin, it presumably redirects the new user to reset their password. What happens, however, is that the user is kicked back to the login page repeatedly. I am guessing that the user is not logged in at this point for some reason, and the reset password page is not being whitelisted.
Please help me understand what this path would be so I can whitelist it, or let me know if there is a more elegant way to approach this.
Charlie
I am trying to override the Account template based on this documentation here: https://dev.to/enisn/you-do-it-wrong-customizing-abp-login-page-correctly-l2k, and when I get to the part where I need to download the Account template so I can override it, I am not able to download it:
I have updated ABP CLI, I have updated ABP Studio, I have logged out and logged back in in the CLI, nothing seems to work. It keeps throwing an error, and the error message doesn't print out any useful information that helps me troubleshoot what is going on.
Additionally, I read somewhere that I can grab source files from ABP Suite, but ABP Suite isn't working for me at all. I go to add my solution, and it throws this:
I would ideally like to get this fixed so I can download files via CLI, but worst case scenario I would be ok if you just zipped it up and sent it to my email on file.
Thanks,
Charlie
Hi, I am attempting to setup self-service registration and subscription for customers. I am having an issue where when I try to go to the payment screen, it throws an error saying "Invalid URL", and when I go to Stripe, it shows a relative URL that I cannot seem to adjust.
For context, here is my register page model OnPostAsync:
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
await OnGetAsync(); // reload editions
return Page();
}
// Confirm we are NOT in tenant context
if (_currentTenant.Id != null)
{
throw new Exception("Cannot register a tenant while already in a tenant context.");
//return Forbid(); // Registration should only be done as host
}
StartSubscriptionResultDto resultDto = await _multiTenancyAppService.RegisterAndSubscribeAsync(new SaasTenantCreateDto
{
Name = Input.TenantName,
AdminEmailAddress = Input.Email,
AdminPassword = Input.Password,
EditionId = Input.EditionId,
ActivationState = Volo.Saas.TenantActivationState.Passive
});
return LocalRedirectPreserveMethod("/Payment/GatewaySelection?paymentRequestId=" + resultDto.PaymentRequestId);
}
And here is my RegisterAndSubscribeAsync method:
public async Task<StartSubscriptionResultDto> RegisterAndSubscribeAsync(SaasTenantCreateDto input)
{
if (input.EditionId == null || input.EditionId == Guid.Empty)
{
throw new UserFriendlyException("Please select a valid edition.");
}
// 1) Create tenant via domain layer (no host permission needed)
var tenant = await _tenantManager.CreateAsync(input.Name, editionId: input.EditionId);
tenant.SetActivationState(input.ActivationState); // keep passive until payment succeeds
await _tenantRepository.InsertAsync(tenant, autoSave: true);
// 2) Publish TenantCreatedEto to seed admin user (same as TenantAppService does)
await _eventBus.PublishAsync(new TenantCreatedEto
{
Id = tenant.Id,
Name = tenant.Name,
Properties =
{
{"AdminEmail", input.AdminEmailAddress},
{"AdminPassword", input.AdminPassword}
}
});
// 3) Start subscription (creates PaymentRequest with TenantId/EditionId extra props)
PaymentRequestWithDetailsDto paymentRequest = await _subscriptionAppService.CreateSubscriptionAsync(input.EditionId ?? Guid.Empty, tenant.Id);
return new StartSubscriptionResultDto
{
TenantId = tenant.Id,
PaymentRequestId = paymentRequest.Id,
};
}
I have tried to set the callbackurl, prepaymenturl, and postpaymenturl in appsettings, but that doesn't seem to do anything (stripe keys redacted for security):
"PaymentWebOptions": {
"RootUrl": "https://armadasoftware.io",
"CallBackUrl": "https://armadasoftware.io/Payment/Stripe/PostPayment",
"PaymentGatewayWebConfigurationDictionary": {
"Stripe": {
"PrePaymentUrl": "https://armadasoftware.io/Payment/Stripe/PrePayment",
"PostPaymentUrl": "https://armadasoftware.io/Payment/Stripe/PostPayment"
}
}
},
"Payment": {
"Stripe": {
"PublishableKey": "",
"SecretKey": "",
"WebhookSecret": ""
}
},
When I get this error, I go to Stripe Webhook logs, and I find this:
So it is saying the URL is invalid, and the success URL is a relative URL, which seems problematic, but I cannot seem to find a configuration or anything in the documentation that allows me to set a success URL such that it overrides this. I tried setting the success url in the stripe section of appsettings, but that didn't work either. Please advise me on how to set this so it overrides whatever default URL is being sent to Stripe here.
I have been working off of this documentation: https://abp.io/docs/latest/solution-templates/layered-web-application/deployment/azure-deployment/step3-deployment-github-action?UI=MVC&DB=EF&Tiered=No, and it does work to deploy to Azure, but if I have the line there to generate openiddict.pfx, it always fails, and it doesn't throw any error message that helps me understand what is going on.
For reference, here is my workflow (I also have never been able to make the DbMigrator work, and I may have to open a new ticket for that too if I can't get it to work):
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions
name: Build and deploy ASP.Net Core app to Azure Web App - armada
on:
push:
branches:
- Armada-ABP-Pro
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
permissions:
contents: read #This is required for actions/checkout
steps:
- uses: actions/checkout@v4
- name: Set up .NET Core
uses: actions/setup-dotnet@v4
with:
dotnet-version: 'v9.0'
- name: Install ABP CLI
run: |
dotnet tool install -g Volo.Abp.Cli
abp install-libs
shell: bash
- name: Build with dotnet
run: dotnet build --configuration Release
- name: dotnet publish
run: dotnet publish -c Release -r win-x64 --self-contained false -o "$env:DOTNET_ROOT\myapp"
shell: pwsh
working-directory: ./src/ArmadaIO.Web # Replace with your project name
env:
ASPNETCORE_ENVIRONMENT: Production
- name: Generate openiddict.pfx
run: dotnet dev-certs https -v -ep ${{env.DOTNET_ROOT}}\myapp\openiddict.pfx -p c41eb3e7-8a8e-429f-9052-0850406f2f11 # Replace with your password
- name: Upload artifact for deployment job
uses: actions/upload-artifact@v4
with:
name: .net-app
path: ${{env.DOTNET_ROOT}}/myapp
deploy:
runs-on: windows-latest
needs: build
environment:
name: 'Production'
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
permissions:
id-token: write #This is required for requesting the JWT
contents: read #This is required for actions/checkout
steps:
- name: Download artifact from build job
uses: actions/download-artifact@v4
with:
name: .net-app
- name: Login to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_D0E4A82237114C8FB52A40680A31F7B7 }}
tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_AF7C7E6475CD4DE9A6FC1907FEBD8DF6 }}
subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_9000905F725645769C73D60324653A76 }}
- name: Deploy to Azure Web App
id: deploy-to-webapp
uses: azure/webapps-deploy@v3
with:
app-name: 'armada'
slot-name: 'Production'
package: .
If I comment out the openiddict line, it runs and deploys (but ofc doesn't actually run and throws a startup error saying it can't find openiddict.pfx). If I uncomment the line, it just shows me this:
and if I pull up the raw logs for that line it shows me this:
2025-08-20T20:23:04.5490958Z ##[group]Run dotnet dev-certs https -v -ep C:\Program Files\dotnet\myapp\openiddict.pfx -p [mypassword]
2025-08-20T20:23:04.5491858Z [36;1mdotnet dev-certs https -v -ep C:\Program Files\dotnet\myapp\openiddict.pfx -p [mypassword][0m
2025-08-20T20:23:04.5526676Z shell: C:\Program Files\PowerShell\7\pwsh.EXE -command ". '{0}'"
2025-08-20T20:23:04.5526989Z env:
2025-08-20T20:23:04.5527153Z DOTNET_ROOT: C:\Program Files\dotnet
2025-08-20T20:23:04.5527379Z ##[endgroup]
2025-08-20T20:23:05.1704144Z Specify --help for a list of available options and commands.
2025-08-20T20:23:05.3106943Z ##[error]Process completed with exit code 1.
So I feel stuck here. I did find this blog post: https://codejack.com/2022/12/deploying-abp-io-to-an-azure-appservice/, and I used it for testing, but that didn't work for me either. Here is my WebModule with the default code, as well as the commented code that I was using to test based on the blog post above:
public override void PreConfigureServices(ServiceConfigurationContext context)
{
var hostingEnvironment = context.Services.GetHostingEnvironment();
var configuration = context.Services.GetConfiguration();
context.Services.PreConfigure<AbpMvcDataAnnotationsLocalizationOptions>(options =>
{
options.AddAssemblyResource(
typeof(ArmadaResource),
typeof(ArmadaIODomainModule).Assembly,
typeof(ArmadaIODomainSharedModule).Assembly,
typeof(ArmadaIOApplicationModule).Assembly,
typeof(ArmadaIOApplicationContractsModule).Assembly,
typeof(ArmadaIOWebModule).Assembly
);
});
PreConfigure<OpenIddictBuilder>(builder =>
{
builder.AddValidation(options =>
{
options.AddAudiences("ArmadaIO");
options.UseLocalServer();
options.UseAspNetCore();
});
});
if (!hostingEnvironment.IsDevelopment())
{
PreConfigure<AbpOpenIddictAspNetCoreOptions>(options =>
{
options.AddDevelopmentEncryptionAndSigningCertificate = false;
});
PreConfigure<OpenIddictServerBuilder>(serverBuilder =>
{
serverBuilder.AddProductionEncryptionAndSigningCertificate("openiddict.pfx", configuration["AuthServer:CertificatePassPhrase"]!);
serverBuilder.SetIssuer(new Uri(configuration["AuthServer:Authority"]!));
//serverBuilder.AddEncryptionCertificate(GetEncryptionCertificate(hostingEnvironment, configuration));
//serverBuilder.AddSigningCertificate(GetSigningCertificate(hostingEnvironment, configuration));
});
}
}
//private X509Certificate2 GetSigningCertificate(IWebHostEnvironment hostingEnv,
// IConfiguration configuration)
//{
// var fileName = $"cert-signing.pfx";
// var passPhrase = configuration["ArmadaCertificate:X590:PassPhrase"];
// var file = Path.Combine(hostingEnv.ContentRootPath, fileName);
// if (File.Exists(file))
// {
// var created = File.GetCreationTime(file);
// var days = (DateTime.Now - created).TotalDays;
// if (days > 180)
// {
// File.Delete(file);
// }
// else
// {
// try
// {
// return new X509Certificate2(file, passPhrase, X509KeyStorageFlags.MachineKeySet);
// }
// catch (CryptographicException)
// {
// File.Delete(file);
// }
// }
// }
// // file doesn't exist, was deleted because it expired, or has an invalid passphrase
// using var algorithm = RSA.Create(keySizeInBits: 2048);
// var subject = new X500DistinguishedName("CN=Armada Signing Certificate");
// var request = new CertificateRequest(subject, algorithm,
// HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// request.CertificateExtensions.Add(new X509KeyUsageExtension(
// X509KeyUsageFlags.DigitalSignature, critical: true));
// var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow,
// DateTimeOffset.UtcNow.AddYears(2));
// File.WriteAllBytes(file, certificate.Export(X509ContentType.Pfx, passPhrase));
// return new X509Certificate2(file, passPhrase, X509KeyStorageFlags.MachineKeySet);
//}
//private X509Certificate2 GetEncryptionCertificate(IWebHostEnvironment hostingEnv,
// IConfiguration configuration)
//{
// var fileName = $"cert-encryption.pfx";
// var passPhrase = configuration["ArmadaCertificate:X590:PassPhrase"];
// var file = Path.Combine(hostingEnv.ContentRootPath, fileName);
// if (File.Exists(file))
// {
// var created = File.GetCreationTime(file);
// var days = (DateTime.Now - created).TotalDays;
// if (days > 180)
// {
// File.Delete(file);
// }
// else
// {
// try
// {
// return new X509Certificate2(file, passPhrase, X509KeyStorageFlags.MachineKeySet);
// }
// catch (CryptographicException)
// {
// File.Delete(file);
// }
// }
// }
// // file doesn't exist, was deleted because it expired, or has an invalid passphrase
// using var algorithm = RSA.Create(keySizeInBits: 2048);
// var subject = new X500DistinguishedName("CN=Armada Encryption Certificate");
// var request = new CertificateRequest(subject, algorithm,
// HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// request.CertificateExtensions.Add(new X509KeyUsageExtension(
// X509KeyUsageFlags.KeyEncipherment, critical: true));
// var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow,
// DateTimeOffset.UtcNow.AddYears(2));
// File.WriteAllBytes(file, certificate.Export(X509ContentType.Pfx, passPhrase));
// return new X509Certificate2(file, passPhrase, X509KeyStorageFlags.MachineKeySet);
//}
I was able to get this working one time in the past, but I can't seem to recreate it unfortunately. I did create an openiddict.pfx using the command in the terminal, then I uploaded it to my Azure certificates. That doesn't seem to work either. Perhaps I need to upload openiddict.pfx directly into the project root in Azure? I am not very knowledgeable about this, so I'm not sure what is safe or what could cause security issues.
Any advice here would be appreciated. Thanks.
I have an open ticket for this (#9614), but I haven't received help so I am opening another to see if I can get help here and maybe get refunded for that ticket.
I created a custom registration page, which I am planning on using to allow customers to sign up themselves, creating a tenant and signing up for an edition at the same time.
Here is my page model for my registration page that will handle creating the tenant and subscribing the edition to that tenant:
[AllowAnonymous]
public class RegisterModel : ArmadaPageModel
{
private readonly ITenantAppService _tenantAppService;
private readonly IMultiTenancyAppService _multiTenancyAppService;
private readonly IEditionAppService _editionAppService;
private readonly ISubscriptionAppService _subscriptionAppService;
private readonly ICurrentTenant _currentTenant;
public RegisterModel(
ITenantAppService tenantAppService,
IMultiTenancyAppService multiTenancyAppService,
IEditionAppService editionAppService,
ISubscriptionAppService subscriptionAppService,
ICurrentTenant currentTenant)
{
_tenantAppService = tenantAppService;
_multiTenancyAppService = multiTenancyAppService;
_editionAppService = editionAppService;
_subscriptionAppService = subscriptionAppService;
_currentTenant = currentTenant;
}
[BindProperty]
public RegisterTenantViewModel Input { get; set; } = new();
public async Task OnGetAsync()
{
List<EditionDto> editions = await _multiTenancyAppService.GetEditionsAsync();
List<SelectListItem> editionSelectItems = editions.Select(e => new SelectListItem
{
Text = e.DisplayName,
Value = e.Id.ToString()
}).ToList();
Input.AvailableEditions = editionSelectItems;
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
await OnGetAsync(); // reload editions
return Page();
}
// Confirm we are NOT in tenant context
if (_currentTenant.Id != null)
{
throw new Exception("Cannot register a tenant while already in a tenant context.");
//return Forbid(); // Registration should only be done as host
}
//var tenantDto = await _tenantAppService.CreateAsync(new SaasTenantCreateDto
//{
// Name = Input.TenantName,
// AdminEmailAddress = Input.Email,
// AdminPassword = Input.Password,
// EditionId = Input.EditionId
//});
var tenantDto = await _multiTenancyAppService.CreateTenantAsync(new SaasTenantCreateDto
{
Name = Input.TenantName,
AdminEmailAddress = Input.Email,
AdminPassword = Input.Password,
EditionId = Input.EditionId
});
// 2. Manually switch to new tenant context
using (_currentTenant.Change(tenantDto.Id))
{
// 3. Create subscription with edition ID
var subscription = await _subscriptionAppService.CreateSubscriptionAsync(
Input.EditionId,
tenantDto.Id
);
// 4. Redirect to Stripe or confirmation
//return Redirect(subscription.PaymentUrl);
}
return Page();
}
}
I also created an appservice called MultiTenancyAppService which helps me populate the editions dropdown, as well as attempt to create a tenant (since calling tenantAppService.CreateAsync() was throwing an authorization error):
[AllowAnonymous]
public class MultiTenancyAppService : ArmadaIOAppService, IMultiTenancyAppService
{
private readonly IRepository<Edition, Guid> _editionRepository;
private readonly ITenantAppService _tenantAppService;
private readonly IRepository<Tenant, Guid> _tenantRepository;
public MultiTenancyAppService(IRepository<Edition, Guid> editionRepository, ITenantAppService tenantAppService, IRepository<Tenant, Guid> tenantRepository)
{
_editionRepository = editionRepository;
_tenantAppService = tenantAppService;
_tenantRepository = tenantRepository;
}
public async Task<List<EditionDto>> GetEditionsAsync()
{
List<Edition> editions = await _editionRepository.GetListAsync();
List<EditionDto> dtos = ObjectMapper.Map<List<Edition>, List<EditionDto>>(editions);
return dtos;
}
public async Task<SaasTenantDto> CreateTenantAsync(SaasTenantCreateDto input)
{
if (string.IsNullOrWhiteSpace(input.Name) || string.IsNullOrWhiteSpace(input.AdminEmailAddress) || string.IsNullOrWhiteSpace(input.AdminPassword))
{
throw new UserFriendlyException("Please fill all required fields before submission");
}
return await _tenantAppService.CreateAsync(input);
}
}
It is still throwing a "Exception of type 'Volo.Abp.Authorization.AbpAuthorizationException' was thrown" error on the "await _tenantAppService.CreateAsync(input);" line when trying to create a tenant, even though I have the [AllowAnonymous] decorator on the page model and the app service. I am under the assumption that by default, a register page is creating a tenant when in host mode, since a tenant isn't logged in, so this seems like unintuitive functionality. I'm assuming part of this is because the page itself is accessible to non-tenant users, but that is necessary figuring the only people registering will be those who are not yet tenants.
Please advise me on a potential solution to this.
Charlie
I am hoping to get a little knowledge to piece together some of these modules into a cohesive unit. I have all of the modules installed, and I followed the documentation to add a Stripe webhook, product and price, and I then used that to configure my StripeOptions. I also setup an Plan, GatewayPlan, and Edition, so theoretically, once I have a page set up to allow customers to subscribe to an edition, it should work. However, I have a few questions that don't seem to be elucidated in the documentation:
In this example code in the documentation: var paymentRequest = await SubscriptionAppService.CreateSubscriptionAsync(editionId, CurrentTenant.GetId());, it seems to imply that the tenant already needs to be a tenant and be signed in to be able to subscribe to an edition. This seems confusing to me. I need my users to be able to sign up for an edition at the same time they sign up to be a tenant (it doesn't make sense to have a tenant without an edition assigned to them). Would I just do this all at the same time?
Related to 1, I don't have a use for the "host" section of the app, and really only need the app accessible via the admin and tenants. Is there a way to "disable" the host (non-tenant) functionality and just force the user to login or register before being redirected to the homepage? Unless host does something else I am unaware of, but I don't want users to be able to access anything without being logged in.
I am stuck on these at the moment, so any advice on how to structure this would be greatly appreciated.
Charlie
When execution reaches my 'throw new UserFriendlyException("string")' line, it throws an actual exception rather than showing as a dialog as it used to do before i upgraded my project to Pro and .NET 9:
I assume there is some misconfiguration here, but I can't find it in any documentation online. Please show me how I can get back to having this display to the user in a dialog.