Key points
Step-by-step
A) Install and wire modules
/Payment/GatewaySelection/Payment/Stripe/PrePaymentB) Configure Payment and SaaS
"Payment": {
"Stripe": {
"PublishableKey": "pk_test_xxx",
"SecretKey": "sk_test_xxx",
"WebhookSecret": "whsec_xxx",
"Currency": "USD", // set as needed
"Locale": "auto",
"PaymentMethodTypes": [] // leave empty; module adds “card” automatically
}
}
public override void PreConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetConfiguration();
Configure<PaymentWebOptions>(options =>
{
options.RootUrl = configuration["App:SelfUrl"];
options.CallbackUrl = configuration["App:SelfUrl"] + "/PaymentSucceed";
});
}
C) Define SaaS Editions, Plans and Stripe mapping
D) Self-service “change plan” UI flow (tenant-facing)
Example MVC page model (tenant UI):
public class ChangePlanModel : PageModel
{
private readonly IPaymentRequestAppService _paymentRequestAppService;
private readonly ICurrentTenant _currentTenant;
public ChangePlanModel(IPaymentRequestAppService paymentRequestAppService, ICurrentTenant currentTenant)
{
_paymentRequestAppService = paymentRequestAppService;
_currentTenant = currentTenant;
}
public async Task<IActionResult> OnPostAsync(Guid editionId, Guid planId)
{
// Carry needed info for webhook/finalization
var pr = await _paymentRequestAppService.CreateAsync(
new PaymentRequestCreateDto
{
Products =
{
new PaymentRequestProductCreateDto
{
PaymentType = PaymentType.Subscription,
Name = "PlanChange",
Code = $"{_currentTenant.Id}_{planId}",
Count = 1,
PlanId = planId
}
}
}.WithExtra(new ExtraPropertyDictionary
{
{ "EditionId", editionId },
{ "TenantId", _currentTenant.Id }
})
);
return LocalRedirectPreserveMethod("/Payment/GatewaySelection?paymentRequestId=" + pr.Id);
}
}
Helper extension (optional):
public static class PaymentRequestDtoExtensions
{
public static PaymentRequestCreateDto WithExtra(this PaymentRequestCreateDto dto, ExtraPropertyDictionary extras)
{
dto.ExtraProperties = extras;
return dto;
}
}
Notes
E) Redirects and built-in UI
F) Webhooks are mandatory
G) Downgrade and upgrade are the same flow
H) If you prefer ISubscriptionAppService
I) Angular UI
Common pitfalls and fixes
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Do the following:
"App": {
"SelfUrl": "https://10.136.36.151:44380",
"RedirectAllowedUrls": "http://localhost:4200,http://10.136.36.151:4200,https://10.136.36.151:4200"
}
Notes:
Include every URL the SPA can use: local dev, IP:port, domain names, and HTTPS variants (if applicable).
If you terminate TLS elsewhere but serve Angular over HTTPS, include the https:// variant.
Restart the AuthServer after changing appsettings.
Ensure the OpenIddict client (NovaLab_App) has the exact redirect URIs
Align Angular environment configuration
Don’t mix schemes unintentionally
Reverse proxy/containers tip
After these changes, restart services. The login should proceed without “invalid redirect_uri”.
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Apply all steps below:
using Volo.Abp.AspNetCore.Mvc.UI.Bundling;
using Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Bundling;
public class ProdigyEnablerWebModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpBundlingOptions>(options =>
{
options.StyleBundles.Configure(BasicThemeBundles.Styles.Global, bundle =>
{
// Path must be relative to web root (wwwroot)
bundle.AddFiles("/styles/global-styles.css");
});
});
}
}
Notes:
<abp-style-bundle name="@BasicThemeBundles.Styles.Global" />
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
var app = context.GetApplicationBuilder();
var env = context.GetEnvironment();
if (!env.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.MapAbpStaticAssets(); // important for ABP’s virtual files
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseConfiguredEndpoints();
}
Ensure client libraries and bundles are present when you build/publish:
Dockerfile (reference pattern)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy solution and restore
COPY ./ ./
RUN dotnet restore
# Install ABP libs for the Web project (adjust path to your Web csproj folder)
WORKDIR /src/YourCompany.YourProj.Web
RUN dotnet tool restore
RUN dotnet tool install -g Volo.Abp.Cli || true
ENV PATH="$PATH:/root/.dotnet/tools"
RUN abp install-libs
# Build and publish
WORKDIR /src
RUN dotnet publish YourCompany.YourProj.Web/YourCompany.YourProj.Web.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "YourCompany.YourProj.Web.dll"]
CI/CD (DigitalOcean)
Verify at runtime
Common pitfalls that cause this error
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Do the following:
import { Environment } from '@abp/ng.core';
const baseUrl = 'http://serviceip:4200'; // or https if you have TLS on the SPA
const oAuthConfig = {
issuer: 'https://serverhost:44380/', // IMPORTANT: public HTTPS authority with trailing slash
redirectUri: baseUrl,
clientId: 'NovaLab_App',
responseType: 'code',
scope: 'offline_access NovaLab',
requireHttps: true,
impersonation: {
tenantImpersonation: true,
userImpersonation: true,
},
};
export const environment = {
production: true,
application: {
baseUrl,
name: 'NovaLab',
},
oAuthConfig,
apis: {
default: {
url: 'https://serverhost:44380', // same host as issuer
rootNamespace: 'RZ.NovaLab',
},
AbpAccountPublic: {
url: oAuthConfig.issuer,
rootNamespace: 'AbpAccountPublic',
},
},
remoteEnv: {
url: '/getEnvConfig',
mergeStrategy: 'deepmerge',
},
} as Environment;
// Program.cs or the module's OnApplicationInitialization:
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor
});
// If your proxy doesn’t set X-Forwarded-Proto properly, force https as a fallback:
app.Use(async (ctx, next) =>
{
ctx.Request.Scheme = "https";
await next();
});
PreConfigure<OpenIddictServerBuilder>(builder =>
{
builder.SetIssuer(new Uri(configuration["AuthServer:Authority"]!)); // https://serverhost:44380
});
After these changes, browsing http://serviceip:4200 (or https if applicable) should fetch the discovery document from https://serverhost:44380/.well-known/openid-configuration instead of https://localhost:44380, and complete the OIDC flow successfully.
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
What ABP does (and does not) do
Why your user-specific TimeZone setting did not change the API output
Why your manual CreationTime change “reverted” after leaving the AppService
Recommended, working setup for your stack (Blazor Server, ABP 9.0.2)
Configure<AbpClockOptions>(options =>
{
options.Kind = DateTimeKind.Utc;
});
@inject IClock Clock
@Clock.ConvertToUserTime(dto.CreationTime).ToString("yyyy-MM-dd HH:mm:ss")
- Input -> Save:
- If the user picked a DateTime in their local time:
dto.ScheduledTime = Clock.ConvertToUtc(userInputLocalDateTime);
- If you use DateTimeOffset pickers, prefer storing DateTimeOffset in the entity/DTO where appropriate, or convert via Clock.ConvertToUtc(offset.DateTime) when persisting as DateTime.
$form.handleDatepicker('input[type="hidden"][data-hidden-datepicker]');
This uses abp.clock.normalizeToString under the hood to send UTC values.
- For displaying in JS (DataTables or custom UI):
abp.clock.normalizeToLocaleString(utcIsoString)
@inject IClock Clock
// In the grid template:
@Clock.ConvertToUserTime(item.CreationTime).ToString("yyyy-MM-dd HH:mm:ss")
// When creating/updating from local picker value:
entity.ScheduledOnUtc = Clock.ConvertToUtc(LocalPickerValue);
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Purpose
Initialization and usage
public class MyService : ApplicationService
{
private readonly IRepository<MySharedType, Guid> _repo;
public MyService(IRepository<MySharedType, Guid> repo)
{
_repo = repo;
}
public async Task<List<MySharedType>> GetFromProductTableAsync()
{
_repo.SetEntityName("Product"); // select the named entity mapping
return await _repo.GetListAsync();
}
}
Note: The default is null; only set a name if you intentionally mapped the same CLR type as different named entities in your DbContext/model configuration.
- ProviderName: It’s read-only from consumer perspective (you just read it). The framework sets it in the concrete repository implementation. You can check it when you need provider-specific logic:
if (_repo.ProviderName == Volo.Abp.EntityFrameworkCore.AbpEfCoreConsts.ProviderName)
{
// EF Core-specific path
}
In custom repository base classes (like your ReadOnlyBasicRepositoryBase<TEntity>):
Minimal implementation suggestions for your base class
public abstract class ReadOnlyBasicRepositoryBase<TEntity> :
IReadOnlyBasicRepository<TEntity>,
IRepository,
IServiceProviderAccessor,
IUnitOfWorkEnabled
where TEntity : class, IEntity
{
// The framework/provider sets this. Expose it as required by the interface.
public string ProviderName { get; protected set; }
// Default null. Let consumers set it when needed via SetEntityName.
public string EntityName { get; protected set; }
public virtual void SetEntityName(string entityName)
{
EntityName = entityName;
}
// ...rest of your implementation
}
If your interface requires get-only for ProviderName, implement a protected setter or assign it in the provider-specific derived class. For EF Core/Mongo/MemDb repositories supplied by ABP, this is already handled. If you’re implementing a fully custom provider, set ProviderName with your constant provider identifier.
When to set them in lifecycle
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Root cause
Temporary workarounds (pick one)
Run Suite under x64 compatibility
Disable JavaScript code formatting in Suite (bypass ClearScript)
Use ABP Studio’s integrated Suite in x64 mode
Recommended next steps
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Why you see “new” values in PostContribute
What ABP exposes for original values today
Recommended approach: compute OriginalValue in PreContribute using the snapshot already captured by ABP
Code sketch:
public class ValueObjectAuditContributor : AuditLogContributor
{
public override void PreContribute(AuditLogContributionContext context)
{
if (context?.AuditInfo?.EntityChanges == null) return;
foreach (var entityChange in context.AuditInfo.EntityChanges)
{
if (entityChange.PropertyChanges == null) continue;
// Example: Suppose Address is a VO with Street, City and you want Original/New of a derived member Full.
// First, gather originals from ABP’s recorded scalar changes.
var originalVoParts = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pc in entityChange.PropertyChanges)
{
// Match VO sub-properties like "Address.Street", "Address.City"
if (pc.PropertyName != null && pc.PropertyName.StartsWith("Address.", StringComparison.Ordinal))
{
var sub = pc.PropertyName.Substring("Address.".Length);
originalVoParts[sub] = pc.OriginalValue; // ABP’s captured original scalar value
}
}
// If there is enough info to reconstruct the VO, build it and compute the derived value
if (originalVoParts.Count > 0)
{
var originalVo = new Address(
street: originalVoParts.TryGetValue("Street", out var s) ? s : null,
city: originalVoParts.TryGetValue("City", out var c) ? c : null
);
// Find or create a synthetic propertyChange entry for the derived member
var fullPc = entityChange.PropertyChanges
.FirstOrDefault(x => x.PropertyName == "Address.Full");
if (fullPc == null)
{
fullPc = new EntityPropertyChangeInfo
{
PropertyName = "Address.Full",
PropertyTypeFullName = "System.String"
};
entityChange.PropertyChanges.Add(fullPc);
}
// Set Original derived value from reconstructed VO
fullPc.OriginalValue = originalVo.Full; // computed at PreContribute time
}
}
}
public override void PostContribute(AuditLogContributionContext context)
{
if (context?.AuditInfo?.EntityChanges == null) return;
foreach (var entityChange in context.AuditInfo.EntityChanges)
{
if (entityChange.PropertyChanges == null) continue;
// Compute New derived value from the current entity (which now holds the new VO)
var entryWrapper = entityChange.EntityEntry;
var entity = entryWrapper?.GetType().GetProperty("Entity")?.GetValue(entryWrapper);
if (entity == null) continue;
var addressProp = entity.GetType().GetProperty("Address");
var newVo = addressProp?.GetValue(entity);
if (newVo == null) continue;
var fullPc = entityChange.PropertyChanges
.FirstOrDefault(x => x.PropertyName == "Address.Full");
if (fullPc == null)
{
fullPc = new EntityPropertyChangeInfo
{
PropertyName = "Address.Full",
PropertyTypeFullName = "System.String"
};
entityChange.PropertyChanges.Add(fullPc);
}
var fullMember = newVo.GetType().GetProperty("Full"); // or method invoke if needed
var newResult = fullMember?.GetValue(newVo)?.ToString();
if (!string.IsNullOrEmpty(newResult))
{
fullPc.NewValue = newResult;
fullPc.PropertyTypeFullName = "System.String";
}
}
}
}
Notes:
The key is PreContribute: it runs before ABP finalizes the audit info and while ABP’s captured OriginalValue for scalar properties is still in the PropertyChanges list. You cannot rely on EF’s EntityEntry to have old in-memory VO instances after SaveChanges.
If the VO is stored as an owned entity with its scalar columns, ABP should record property changes for those columns when they actually change. If not, add the owned scalar properties to the EntityHistory selectors and ensure EF detects the changes.
Alternative: capture a pre-save snapshot in a SaveChanges interceptor
Is there an existing built-in way for VO-derived members?
Practical checklist
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Do this:
using YourCompany.YourAuthServer;
var builder = WebApplication.CreateBuilder(args);
// host configuration (optional)
builder.Host
.AddAppSettingsSecretsJson()
.UseAutofac();
// logging configuration (optional)
builder.Host.UseSerilog();
// add ABP application
await builder.AddApplicationAsync<YourAuthServerModule>();
var app = builder.Build();
await app.InitializeApplicationAsync();
await app.RunAsync();
builder.Configuration
.SetBasePath(builder.Environment.ContentRootPath)
.AddJsonFile($"appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true);
try
{
await builder.AddApplicationAsync<YourAuthServerModule>();
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString()); // inspect the real inner cause (often missing config or incompatible package)
throw;
}
Common actual causes found during 7.x → 9.x migrations:
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
What that settings page is for
A) Configure Microsoft 365 SMTP (OAuth2) for ABP Emailing
B) What each field means (SMTP OAuth2 context)
C) ABP Identity “OAuth Login Settings” (Authority, Client Id, Scope) are for user sign-in to your app, not for SMTP
D) ABP code/config pointers
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.