From 11e6fb62a4b7ea84623a297abe0782f5f5602c53 Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Sat, 28 Mar 2026 11:02:34 -0400 Subject: [PATCH] fix(square): Fix Square flow. Both UI and API --- .../Controllers/OnboardingController.cs | 4 +- .../Controllers/OrganizationController.cs | 7 +- JobFlow.API/Controllers/PaymentController.cs | 137 ++++++++++++++++-- JobFlow.API/Mappings/MapsterConfig.cs | 4 +- JobFlow.API/Program.cs | 5 +- .../Models/DTOs/OrganizationDto.cs | 1 + .../Onboarding/OnboardingCatalog.cs | 10 +- .../Services/OrganizationClientService.cs | 6 - .../Services/PaymentProfileService.cs | 29 ++++ .../IPaymentProfileService.cs | 2 + .../Services/SubscriptionRecordService.cs | 16 ++ .../Square/SquarePaymentProcessor.cs | 8 + .../Stripe/StripePaymentProcessor.cs | 39 ++++- .../Stripe/StripeWebhookService.cs | 89 ++++++++++++ 14 files changed, 328 insertions(+), 29 deletions(-) diff --git a/JobFlow.API/Controllers/OnboardingController.cs b/JobFlow.API/Controllers/OnboardingController.cs index 64d9cce..818dda1 100644 --- a/JobFlow.API/Controllers/OnboardingController.cs +++ b/JobFlow.API/Controllers/OnboardingController.cs @@ -50,12 +50,12 @@ public async Task ApplyQuickStart([FromBody] OnboardingQuickStartApplyR return orgResult.ToProblemDetails(); } - if (!HasMinPlan(orgResult.Value.SubscriptionPlanName, "Flow")) + if (!HasMinPlan(orgResult.Value.SubscriptionPlanName, "Go")) { return Results.Problem( statusCode: StatusCodes.Status403Forbidden, title: "Subscription Required", - detail: "A Flow plan is required to apply quick-start presets."); + detail: "A Go plan is required to apply quick-start presets."); } var result = await onboarding.ApplyQuickStartAsync(organizationId, request); diff --git a/JobFlow.API/Controllers/OrganizationController.cs b/JobFlow.API/Controllers/OrganizationController.cs index 84c49a6..593f483 100644 --- a/JobFlow.API/Controllers/OrganizationController.cs +++ b/JobFlow.API/Controllers/OrganizationController.cs @@ -6,6 +6,7 @@ using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Enums; using JobFlow.Domain.Models; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace JobFlow.API.Controllers; @@ -14,7 +15,6 @@ namespace JobFlow.API.Controllers; [ApiController] public class OrganizationController : ControllerBase { - private readonly INotificationService _notificationService; private readonly IOrganizationBrandingService _organizationBrandingService; private readonly IOrganizationService _organizationService; private readonly IPaymentProfileService _paymentProfileService; @@ -24,14 +24,12 @@ public OrganizationController( IOrganizationService organizationService, IUserService userService, IPaymentProfileService paymentProfileService, - INotificationService notificationService, IOrganizationBrandingService organizationBrandingService ) { _organizationService = organizationService; _userService = userService; _paymentProfileService = paymentProfileService; - _notificationService = notificationService; _organizationBrandingService = organizationBrandingService; } @@ -45,10 +43,10 @@ public async Task GetAllOrganizations() [HttpPost] [Route("create")] + [AllowAnonymous] public async Task CreateOrganizationAccount(Organization model) { var result = await _organizationService.UpsertOrganization(model); - if (result.IsSuccess) await _notificationService.SendOrganizationWelcomeNotificationAsync(model); return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails(); } @@ -96,6 +94,7 @@ await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync(model.FireBaseUid, [HttpPost] [Route("retrieve")] + [AllowAnonymous] public async Task GetOrganizationById([FromBody] OrganizationRequest org) { var result = await _organizationService.GetOrganizationDtoById(org.OrganizationId); diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index ad27684..235735d 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -3,6 +3,7 @@ using JobFlow.Business.PaymentGateways.SharedModels; using JobFlow.Business.ConfigurationSettings.ConfigurationInterfaces; using JobFlow.Business.Services.ServiceInterfaces; +using JobFlow.Business.Onboarding; using JobFlow.Domain.Enums; using JobFlow.API.Extensions; using JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; @@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authentication.JwtBearer; using System.Net.Http.Json; +using System.Security.Cryptography; using System.Text.Json; using Stripe; using CreateSubscriptionRequest = JobFlow.Business.Models.DTOs.CreateSubscriptionRequest; @@ -38,6 +40,7 @@ public class PaymentController : ControllerBase private readonly ISquareTokenEncryptionService _squareTokenEncryption; private readonly IHostEnvironment _hostEnvironment; private readonly IFrontendSettings _frontEndSettings; + private readonly IOnboardingService _onboardingService; public PaymentController( IPaymentProcessorFactory processorFactory, @@ -51,7 +54,8 @@ public PaymentController( ISquareSettings squareSettings, ISquareTokenEncryptionService squareTokenEncryption, IHostEnvironment hostEnvironment, - IFrontendSettings frontEndSettings) + IFrontendSettings frontEndSettings, + IOnboardingService onboardingService) { _processorFactory = processorFactory; _organizationService = organizationService; @@ -65,12 +69,27 @@ public PaymentController( _squareTokenEncryption = squareTokenEncryption; _hostEnvironment = hostEnvironment; _frontEndSettings = frontEndSettings; + _onboardingService = onboardingService; } [HttpPost("checkout")] + [AllowAnonymous] public async Task Checkout([FromBody] PaymentSessionRequest request) { - var orgId = HttpContext.GetOrganizationId(); + Guid orgId; + if (User?.Identity?.IsAuthenticated == true) + { + orgId = HttpContext.GetOrganizationId(); + } + else + { + var requestedOrgId = request.OrgId ?? request.OrganizationId; + if (!requestedOrgId.HasValue || requestedOrgId.Value == Guid.Empty) + return BadRequest("Organization id is required."); + + orgId = requestedOrgId.Value; + } + request.OrgId = orgId; var org = await _organizationService.GetOrganiztionById(orgId); @@ -117,9 +136,17 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque } } - var processor = provider == PaymentProvider.Square - ? await _processorFactory.GetProcessorForOrgAsync(orgId, provider) - : _processorFactory.GetProcessor(provider); + IPaymentProcessor processor; + try + { + processor = provider == PaymentProvider.Square + ? await GetSquareProcessorForCheckoutAsync(orgId, provider) + : _processorFactory.GetProcessor(provider); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } string checkoutUrl; if (request.Mode == "subscription") @@ -130,7 +157,15 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque { request.ConnectedAccountId = organization.StripeConnectAccountId; } - var paymentIntent = await processor.CreatePaymentIntentAsync(request); + PaymentSessionResult paymentIntent; + try + { + paymentIntent = await processor.CreatePaymentIntentAsync(request); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } return Ok(new { @@ -142,6 +177,18 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque return Ok(new { url = checkoutUrl }); } + private async Task GetSquareProcessorForCheckoutAsync(Guid orgId, PaymentProvider provider) + { + try + { + return await _processorFactory.GetProcessorForOrgAsync(orgId, provider); + } + catch (CryptographicException) + { + throw new InvalidOperationException("Your Square connection has expired. Disconnect and reconnect Square, then try checkout again."); + } + } + [HttpPost("create-connected-account")] public async Task CreateConnectedAccount( [FromQuery] PaymentProvider? provider = null) @@ -225,9 +272,35 @@ public async Task LinkConnectedAccount([FromBody] LinkConnectedAc return BadRequest(profileResult.Error); var orgUpdate = await _organizationService.UpsertOrganization(organization); + if (orgUpdate.IsSuccess) + await _onboardingService.MarkStepCompleteAsync(orgId, OnboardingStepKeys.ConnectStripe); + return orgUpdate.IsSuccess ? Ok(new { linked = true }) : BadRequest(orgUpdate.Error); } + [HttpDelete("square/disconnect")] + public async Task DisconnectSquare() + { + var orgId = HttpContext.GetOrganizationId(); + var orgResult = await _organizationService.GetOrganiztionById(orgId); + if (orgResult.IsFailure) + return NotFound(orgResult.Error); + + var organization = orgResult.Value; + organization.IsSquareConnected = false; + organization.SquareMerchantId = null; + + var updateResult = await _organizationService.UpsertOrganization(organization); + if (updateResult.IsFailure) + return BadRequest(updateResult.Error); + + var profileDisconnectResult = await _paymentProfileService.DisconnectOrganizationProviderAsync(orgId, PaymentProvider.Square); + if (profileDisconnectResult.IsFailure) + return BadRequest(profileDisconnectResult.Error); + + return Ok(new { disconnected = true }); + } + [HttpPost("refund")] public async Task RefundPayment([FromBody] PaymentRefundRequestDto request) { @@ -348,13 +421,24 @@ public async Task CreatePaymentProfile([FromBody] CreatePaymentPr // POST: api/payments/subscription [HttpPost("subscription")] + [AllowAnonymous] public async Task CreateSubscription([FromBody] CreateSubscriptionRequest request) { + if (request.PaymentProfileId == Guid.Empty) + return BadRequest("Payment profile is required."); + + if (string.IsNullOrWhiteSpace(request.ProviderSubscriptionId)) + return BadRequest("Provider subscription ID is required."); + + var normalizedStatus = NormalizeSubscriptionStatus(request.Status); + if (normalizedStatus is null) + return BadRequest("Invalid subscription status."); + var result = await _subscriptionRecordService.CreateAsync( request.PaymentProfileId, request.ProviderSubscriptionId, request.ProviderPriceId, - request.Status ?? "active", + normalizedStatus, "" ); @@ -363,16 +447,49 @@ public async Task CreateSubscription([FromBody] CreateSubscriptio // POST: api/payments/subscription/cancel [HttpPost("subscription/cancel")] + [AllowAnonymous] public async Task CancelSubscription([FromBody] CancelSubscriptionRequest request) { + if (string.IsNullOrWhiteSpace(request.ProviderSubscriptionId)) + return BadRequest("Provider subscription ID is required."); + + var subscriptionResult = await _subscriptionRecordService.GetByProviderIdAsync(request.ProviderSubscriptionId); + if (subscriptionResult.IsFailure) + return NotFound(subscriptionResult.Error); + + if (string.Equals(subscriptionResult.Value.Status, "canceled", StringComparison.OrdinalIgnoreCase)) + return Ok(); + + var canceledAt = request.CanceledAt == default ? DateTime.UtcNow : request.CanceledAt.ToUniversalTime(); + var result = await _subscriptionRecordService.CancelAsync( request.ProviderSubscriptionId, - request.CanceledAt + canceledAt ); return result.IsSuccess ? Ok() : BadRequest(result.Error); } + private static string? NormalizeSubscriptionStatus(string? status) + { + var normalized = string.IsNullOrWhiteSpace(status) + ? "active" + : status.Trim().ToLowerInvariant(); + + return normalized switch + { + "active" => "active", + "trialing" => "trialing", + "past_due" => "past_due", + "incomplete" => "incomplete", + "incomplete_expired" => "incomplete_expired", + "unpaid" => "unpaid", + "paused" => "paused", + "canceled" => "canceled", + _ => null + }; + } + // POST: api/payments/profile/default-method [HttpPost("profile/default-method")] public async Task SetDefaultPaymentMethod([FromBody] SetDefaultPaymentMethodRequest request) @@ -526,6 +643,8 @@ public async Task HandleSquareCallback( if (updateResult.IsFailure) return Redirect($"{uiBase}?provider=square&success=false&error={Uri.EscapeDataString(updateResult.Error?.ToString() ?? "Unable to update organization payment provider.")}"); + await _onboardingService.MarkStepCompleteAsync(organizationId, OnboardingStepKeys.ConnectStripe); + return Redirect($"{uiBase}?provider=square&success=true&merchantId={Uri.EscapeDataString(merchantId)}"); } @@ -540,7 +659,7 @@ private string BuildSquareOnboardingUrl(string orgState) ? "https://connect.squareupsandbox.com" : "https://connect.squareup.com"; - return $"{connectBaseUrl}/oauth2/authorize?client_id={Uri.EscapeDataString(_squareSettings.ApplicationId)}&response_type=code&scope=PAYMENTS_WRITE+PAYMENTS_READ+ORDERS_READ+SUBSCRIPTIONS_READ+SUBSCRIPTIONS_WRITE&state={Uri.EscapeDataString(orgState)}&redirect_uri={Uri.EscapeDataString(_squareSettings.RedirectUrl)}"; + return $"{connectBaseUrl}/oauth2/authorize?client_id={Uri.EscapeDataString(_squareSettings.ApplicationId)}&response_type=code&scope=PAYMENTS_WRITE+PAYMENTS_READ+ORDERS_READ+ORDERS_WRITE+SUBSCRIPTIONS_READ+SUBSCRIPTIONS_WRITE&state={Uri.EscapeDataString(orgState)}&redirect_uri={Uri.EscapeDataString(_squareSettings.RedirectUrl)}"; } private string BuildSquareUiRedirectBaseUrl() diff --git a/JobFlow.API/Mappings/MapsterConfig.cs b/JobFlow.API/Mappings/MapsterConfig.cs index e78ef9d..4ef2fd6 100644 --- a/JobFlow.API/Mappings/MapsterConfig.cs +++ b/JobFlow.API/Mappings/MapsterConfig.cs @@ -27,7 +27,9 @@ public void Register(TypeAdapterConfig config) config.NewConfig(); // Organization → DTO - config.NewConfig(); + config.NewConfig() + .Map(dest => dest.Email, src => src.EmailAddress) + .Map(dest => dest.EmailAddress, src => src.EmailAddress); //OrganizationBranding → DTO config.NewConfig(); diff --git a/JobFlow.API/Program.cs b/JobFlow.API/Program.cs index 57f4aaf..4a6d3d7 100644 --- a/JobFlow.API/Program.cs +++ b/JobFlow.API/Program.cs @@ -389,7 +389,10 @@ app.UseSwaggerUI(); } -app.UseHttpsRedirection(); +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} app.UseExceptionHandler(app.Environment.IsProduction() ? "/error" : "/error-development"); app.UseRouting(); diff --git a/JobFlow.Business/Models/DTOs/OrganizationDto.cs b/JobFlow.Business/Models/DTOs/OrganizationDto.cs index eec4b1b..64f8251 100644 --- a/JobFlow.Business/Models/DTOs/OrganizationDto.cs +++ b/JobFlow.Business/Models/DTOs/OrganizationDto.cs @@ -29,6 +29,7 @@ public class OrganizationDto public Guid? Id { get; set; } public string? OrganizationName { get; set; } public string? Email { get; set; } + public string? EmailAddress { get; set; } public string? Address1 { get; set; } public string? Address2 { get; set; } public string? City { get; set; } diff --git a/JobFlow.Business/Onboarding/OnboardingCatalog.cs b/JobFlow.Business/Onboarding/OnboardingCatalog.cs index 00b4cf5..ac18774 100644 --- a/JobFlow.Business/Onboarding/OnboardingCatalog.cs +++ b/JobFlow.Business/Onboarding/OnboardingCatalog.cs @@ -18,10 +18,10 @@ public static class OnboardingCatalog { OnboardingStepKeys.ConnectStripe, 10 }, { OnboardingStepKeys.CreateCustomer, 20 }, { OnboardingStepKeys.CreateJob, 30 }, - { OnboardingStepKeys.CreateInvoice, 40 }, - { OnboardingStepKeys.SendInvoice, 50 }, - { OnboardingStepKeys.ReceivePayment, 60 }, - { OnboardingStepKeys.ScheduleJob, 70 } + { OnboardingStepKeys.ScheduleJob, 40 }, + { OnboardingStepKeys.CreateInvoice, 50 }, + { OnboardingStepKeys.SendInvoice, 60 }, + { OnboardingStepKeys.ReceivePayment, 70 } }; private static readonly Dictionary OrganizedFirstOrder = new() @@ -45,7 +45,7 @@ public static class OnboardingCatalog new(OnboardingStepKeys.CreateJob, "Create your first job", 20, _ => true), new(OnboardingStepKeys.ScheduleJob, "Schedule the job", 30, _ => true), new(OnboardingStepKeys.CreateInvoice, "Create an invoice", 40, _ => true), - new(OnboardingStepKeys.ConnectStripe, "Create Stripe connected account", 50, _ => true), + new(OnboardingStepKeys.ConnectStripe, "Connect your payment account", 50, _ => true), new(OnboardingStepKeys.SendInvoice, "Send the invoice", 60, _ => true), new( OnboardingStepKeys.ReceivePayment, diff --git a/JobFlow.Business/Services/OrganizationClientService.cs b/JobFlow.Business/Services/OrganizationClientService.cs index 56d0605..456a043 100644 --- a/JobFlow.Business/Services/OrganizationClientService.cs +++ b/JobFlow.Business/Services/OrganizationClientService.cs @@ -68,9 +68,6 @@ public async Task DeleteClient(Guid clientId, Guid organizationId) public async Task>> GetAllClients() { var clients = await organizationClient.Query().ToListAsync(); - if (!clients.Any()) - return Result.Failure>(OrganizationClientErrors.NoClientsToShow); - return Result.Success>(clients); } @@ -78,9 +75,6 @@ public async Task>> GetAllClientsByOrgani { var clients = await organizationClient.Query().Where(client => client.OrganizationId == organizationId) .ToListAsync(); - if (!clients.Any()) - return Result.Failure>(OrganizationClientErrors.NoClientFound); - return Result.Success>(clients); } diff --git a/JobFlow.Business/Services/PaymentProfileService.cs b/JobFlow.Business/Services/PaymentProfileService.cs index cf240d3..c2a2bfb 100644 --- a/JobFlow.Business/Services/PaymentProfileService.cs +++ b/JobFlow.Business/Services/PaymentProfileService.cs @@ -185,4 +185,33 @@ public async Task UpdateTokensAsync(Guid profileId, string encryptedAcce return Result.Success(); } + + public async Task DisconnectOrganizationProviderAsync(Guid organizationId, PaymentProvider provider) + { + if (organizationId == Guid.Empty) + return Result.Failure(PaymentProfileErrors.NullOrEmptyOwnerId); + + var profiles = await paymentProfiles.Query() + .Where(p => p.OwnerId == organizationId + && p.OwnerType == PaymentEntityType.Organization + && p.Provider == provider) + .ToListAsync(); + + if (profiles.Count == 0) + return Result.Success(); + + foreach (var profile in profiles) + { + profile.ProviderCustomerId = string.Empty; + profile.DefaultPaymentMethodId = null; + profile.EncryptedAccessToken = null; + profile.EncryptedRefreshToken = null; + profile.TokenExpiresAtUtc = null; + profile.SquareLocationId = null; + profile.UpdatedAt = DateTime.UtcNow; + } + + await unitOfWork.SaveChangesAsync(); + return Result.Success(); + } } \ No newline at end of file diff --git a/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs b/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs index e1e9ff6..61f72f1 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IPaymentProfileService.cs @@ -24,4 +24,6 @@ Task> UpsertWithTokensAsync( Task UpdateTokensAsync(Guid profileId, string encryptedAccessToken, string encryptedRefreshToken, DateTime tokenExpiresAtUtc); + + Task DisconnectOrganizationProviderAsync(Guid organizationId, PaymentProvider provider); } \ No newline at end of file diff --git a/JobFlow.Business/Services/SubscriptionRecordService.cs b/JobFlow.Business/Services/SubscriptionRecordService.cs index 266637a..c23a831 100644 --- a/JobFlow.Business/Services/SubscriptionRecordService.cs +++ b/JobFlow.Business/Services/SubscriptionRecordService.cs @@ -32,10 +32,26 @@ public async Task> CreateAsync(Guid paymentProfileId, if (string.IsNullOrWhiteSpace(providerSubscriptionId)) return Result.Failure(SubscriptionErrors.MissingProviderSubscriptionId); + + providerSubscriptionId = providerSubscriptionId.Trim(); + var paymentProfile = await paymentProfiles.Query().FirstOrDefaultAsync(p => p.Id == paymentProfileId); if (paymentProfile == null) return Result.Failure(SubscriptionErrors.InvalidPaymentProfile); + var existing = await subscriptions.Query() + .FirstOrDefaultAsync(s => s.ProviderSubscriptionId == providerSubscriptionId); + + if (existing is not null) + { + if (existing.PaymentProfileId != paymentProfileId) + return Result.Failure(Error.Conflict( + "Subscription", + "Provider subscription ID is already linked to a different payment profile.")); + + return Result.Success(existing); + } + var subscription = new SubscriptionRecord { Id = Guid.NewGuid(), diff --git a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs index 19ea474..5265d12 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs @@ -108,6 +108,14 @@ public async Task CreateCheckoutSessionAsync(PaymentSessionRequest reque } catch (SquareApiException ex) { + if (ex.Message.Contains("INSUFFICIENT_SCOPES", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("ORDERS_WRITE", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Square account needs updated permissions (ORDERS_WRITE). Please reconnect Square from Connected Payment to re-authorize the required scopes.", + ex); + } + throw new Exception($"Square API error: {ex.Message}", ex); } catch (Exception ex) when (ex is not InvalidOperationException) diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs index 065647d..648a061 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs @@ -200,7 +200,10 @@ public async Task CreateSubscriptionCheckoutSessionAsync(PaymentSessionR Quantity = request.Quantity } }, - SuccessUrl = $"{request.SuccessUrl}?organizationId={request.OrgId}&session_id={{CHECKOUT_SESSION_ID}}", + SuccessUrl = BuildSubscriptionSuccessUrl( + request.SuccessUrl, + ownerId, + "{CHECKOUT_SESSION_ID}"), CancelUrl = request.CancelUrl, Customer = customerId, SubscriptionData = new SessionSubscriptionDataOptions @@ -239,4 +242,38 @@ public async Task CreateStripeCustomerAsync(string email) return customer.Id; } + + private static string BuildSubscriptionSuccessUrl( + string? baseUrl, + string organizationId, + string sessionIdPlaceholder) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new InvalidOperationException("Success URL is required for subscription checkout."); + + var urlWithoutFragment = baseUrl; + var fragment = string.Empty; + var fragmentIndex = baseUrl.IndexOf('#'); + if (fragmentIndex >= 0) + { + urlWithoutFragment = baseUrl[..fragmentIndex]; + fragment = baseUrl[fragmentIndex..]; + } + + var queryIndex = urlWithoutFragment.IndexOf('?'); + var path = queryIndex >= 0 ? urlWithoutFragment[..queryIndex] : urlWithoutFragment; + var query = queryIndex >= 0 ? urlWithoutFragment[(queryIndex + 1)..] : string.Empty; + + var parts = query + .Split('&', StringSplitOptions.RemoveEmptyEntries) + .Where(p => + !p.StartsWith("organizationId=", StringComparison.OrdinalIgnoreCase) + && !p.StartsWith("session_id=", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + parts.Add($"organizationId={Uri.EscapeDataString(organizationId)}"); + parts.Add($"session_id={sessionIdPlaceholder}"); + + return $"{path}?{string.Join("&", parts)}{fragment}"; + } } \ No newline at end of file diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs index bbd1704..7bfe11f 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs @@ -248,6 +248,8 @@ private async Task HandleCheckoutSessionAsync(Session session) planName = "Unknown"; } + var existingSubscription = await _subscriptionRecordService.GetByProviderIdAsync(subscription.Id); + var paymentProfileResult = await _paymentProfileService.UpsertAsync( Guid.Parse(ownerId), Enum.Parse(ownerType), @@ -262,6 +264,11 @@ await _subscriptionRecordService.CreateAsync( subscription.Status, planName ); + + if (existingSubscription.IsFailure) + { + await TrySendOrganizationWelcomeAsync(ownerId, ownerType, subscription.Status, subscription.Id, StripeEvents.CheckoutSessionCompleted); + } } private async Task HandlePaymentIntentAsync(PaymentIntent intent) @@ -402,10 +409,19 @@ private async Task HandleSubscriptionUpdatedAsync(Subscription subscription) if (!subResult.IsSuccess) return; + var previousStatus = subResult.Value.Status; + subResult.Value.Status = subscription.Status; subResult.Value.ProviderPriceId = subscription.Items.Data.First().Price.Id; await _subscriptionRecordService.UpdateAsync(subResult.Value); + + if (!IsSubscriptionCompleteStatus(previousStatus) + && IsSubscriptionCompleteStatus(subscription.Status) + && TryGetSubscriptionOwnerMetadata(subscription, out var ownerId, out var ownerType)) + { + await TrySendOrganizationWelcomeAsync(ownerId, ownerType, subscription.Status, subscription.Id, StripeEvents.CustomerSubscriptionUpdated); + } } private async Task HandleSubscriptionCreatedAsync(Subscription subscription) @@ -434,6 +450,8 @@ private async Task HandleSubscriptionCreatedAsync(Subscription subscription) planName = "Unknown"; } + var existingSubscription = await _subscriptionRecordService.GetByProviderIdAsync(subscription.Id); + var paymentProfileResult = await _paymentProfileService.UpsertAsync( Guid.Parse(ownerId), Enum.Parse(ownerType), @@ -448,6 +466,77 @@ await _subscriptionRecordService.CreateAsync( subscription.Status, planName ); + + if (existingSubscription.IsFailure) + { + await TrySendOrganizationWelcomeAsync(ownerId, ownerType, subscription.Status, subscription.Id, StripeEvents.SubscriptionCreated); + } + } + + private async Task TrySendOrganizationWelcomeAsync( + string ownerIdRaw, + string ownerTypeRaw, + string? subscriptionStatus, + string subscriptionId, + string sourceEvent) + { + if (!IsSubscriptionCompleteStatus(subscriptionStatus)) + return; + + if (!Enum.TryParse(ownerTypeRaw, true, out var ownerType) + || ownerType != PaymentEntityType.Organization) + return; + + if (!Guid.TryParse(ownerIdRaw, out var organizationId)) + { + _logger.LogWarning( + "Unable to parse organization owner id for welcome notification. SubscriptionId={SubscriptionId}, OwnerId={OwnerId}, EventType={EventType}", + subscriptionId, + ownerIdRaw, + sourceEvent); + return; + } + + var orgResult = await _organizationService.GetOrganiztionById(organizationId); + if (orgResult.IsFailure) + { + _logger.LogWarning( + "Organization not found for welcome notification. OrganizationId={OrganizationId}, SubscriptionId={SubscriptionId}, EventType={EventType}", + organizationId, + subscriptionId, + sourceEvent); + return; + } + + await _notificationService.SendOrganizationWelcomeNotificationAsync(orgResult.Value); + } + + private static bool IsSubscriptionCompleteStatus(string? status) + { + return string.Equals(status, "active", StringComparison.OrdinalIgnoreCase) + || string.Equals(status, "trialing", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryGetSubscriptionOwnerMetadata( + Subscription subscription, + out string ownerId, + out string ownerType) + { + ownerId = string.Empty; + ownerType = string.Empty; + + if (subscription.Metadata == null) + return false; + + if (!subscription.Metadata.TryGetValue("ownerId", out var ownerIdValue) + || !subscription.Metadata.TryGetValue("ownerType", out var ownerTypeValue)) + return false; + + ownerId = ownerIdValue ?? string.Empty; + ownerType = ownerTypeValue ?? string.Empty; + + return !string.IsNullOrWhiteSpace(ownerId) + && !string.IsNullOrWhiteSpace(ownerType); } private async Task HandleSubscriptionTrialWillEndAsync(Subscription subscription)