Thanks, I emailed you to request the source code.
So I understand, how would that be implemented then? Would the gateway pull in the email from the registration into the payment request, since that is what is being passed to Stripe?
Can you explain this in more detail please? If self-registration is disabled on the host side, will it still allow self-registration for tenants? I need tenants to be able to sign up themselves, but I don't want anyone accessing the host side.
If this will work for my purposes, then is there some code I can implement that will set that rather than configuring it in the host settings in the app? Mainly so it is more streamlined when I need to deploy elsewhere.
Well, I set it up this way because I didn't want to utilize the "host" section of the app (as in, I wanted all users to be tenants), and I wanted to make sure that whenever someone was not logged in, they were redirected to the login page. This seemed like a good way to do it at the time since it forced login before users could access the app and it forced them to be tenants.
Is there a better way to go about this?
public async Task<StartSubscriptionResultDto> RegisterAndSubscribeAsync(SaasTenantCreateDto input)
{
if (input.EditionId == null || input.EditionId == Guid.Empty)
{
throw new UserFriendlyException("Please select a valid edition.");
}
if (string.IsNullOrWhiteSpace(input.Name) || string.IsNullOrWhiteSpace(input.AdminEmailAddress) || string.IsNullOrWhiteSpace(input.AdminPassword))
{
throw new UserFriendlyException("Please fill all required fields before submission");
}
bool isEmailUnique = await IsEmailUnique(input.AdminEmailAddress);
if (!isEmailUnique)
{
// Check that email is unique across tenants
using (_dataFilter.Disable<IMultiTenant>())
{
// Throw error is tenant is active
List<IdentityUser> users = await _userRepository.GetListAsync();
IdentityUser? userWithSameEmail = users.FirstOrDefault(u => u.NormalizedEmail == input.AdminEmailAddress.Trim().ToUpperInvariant());
Tenant? associatedTenant = await _tenantRepository.FindAsync(userWithSameEmail?.TenantId ?? Guid.Empty);
if (associatedTenant != null && !associatedTenant.IsDeleted)
{
throw new UserFriendlyException("Email address is already registered. Please use another email address.");
}
}
}
// 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);
string email = input.AdminEmailAddress.Trim().ToLowerInvariant();
// 2) Publish TenantCreatedEto to seed admin user (same as TenantAppService does)
await _eventBus.PublishAsync(new TenantCreatedEto
{
Id = tenant.Id,
Name = tenant.Name,
Properties =
{
{"AdminEmail", email},
{"AdminUserName", email },
{"AdminPassword", input.AdminPassword}
}
});
// 3) Start subscription (creates PaymentRequest with TenantId/EditionId extra props)
PaymentRequestWithDetailsDto? paymentRequest = null;
try
{
paymentRequest = await _subscriptionAppService.CreateSubscriptionAsync(input.EditionId ?? Guid.Empty, tenant.Id);
}
catch
{
// No payment plan configured. Go directly to activation
await ActivateTenantAsync(tenant.Id);
}
return new StartSubscriptionResultDto
{
TenantId = tenant.Id,
PaymentRequestId = paymentRequest?.Id ?? Guid.Empty,
};
}
I am seeing very little documentation which would support or even elucidate anything related to this. I checked PaymentRequestCreateDto and PaymentRequestWithDetailsDto, and it seems like ExtraProperties has protected set, so I can't just set it outright. In addition, the AI recommends using _paymentRequestAppService.CreateAsync(), but I would like to continue using _subscriptionAppService.CreateSubscriptionAsync() unless there is a good reason not to, since it seems to handle a lot of aspects surrounding the subscription and multitenancy.
I'm not sure if I am misunderstanding something, or if the AI is incorrect, but any help here would be 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?
That doesn't really recommend anything aside from removing the whitelisting on some of my other pages, which I need for other purposes in my app (my own Stripe implementation).
Any help would be appreciated.
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
So nuking the databases (along with changing a connection string I missed, which might have been a big part of it) worked and everything is functional. I guess I was mostly just rubber duck debugging here so I will close this out.
Based on everything I was seeing, it seems to me that it was attempting to use my local migrations to figure out what needs to go to the azure database. Unfortunately my migrations were very messed up (I made some changes to my project awhile ago and had some source control issues, so a lot of code got messed up), so I decided to nuke both of them and make fresh databases and a brand new initial migration, since I am still in development and can do so with very few downsides.
If this ends up working, I will close the ticket.