From 04fe44c88ba1f355e8de5bf65aaf1d9e3017abbd Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Mon, 25 May 2026 16:48:09 -0400 Subject: [PATCH] fix: complete mobile payment workflow end-to-end - AssignmentDto: add BalanceDue and InvoiceId fields - AssignmentService: pre-fetch latest invoice per job, populate BalanceDue on assignments - IPaymentProcessor: add CreateOneTimeCheckoutSessionAsync - StripePaymentProcessor: implement CreateOneTimeCheckoutSessionAsync using Stripe Checkout Sessions (hosted page with redirect URL) instead of PaymentIntents (client secret only) - SquarePaymentProcessor: implement CreateOneTimeCheckoutSessionAsync (delegates to existing hosted checkout flow) - PaymentSessionRequest: add JobId field - PaymentController.Checkout: route one-time payments through CreateOneTimeCheckoutSessionAsync; when JobId provided without InvoiceId, call EnsureInvoiceForPaymentAsync to auto-resolve - IInvoiceService / InvoiceService: add EnsureInvoiceForPaymentAsync - finds existing invoice, falls back to creating from accepted estimate (Option A), falls back to quick single-line-item draft invoice from provided amount (Option B) - StripeWebhookService: fix HandleCheckoutSessionAsync to handle mode=payment sessions (previously bailed immediately on missing SubscriptionId); on success calls MarkPaidAsync, fires client and org payment notifications, marks onboarding step complete --- JobFlow.API/Controllers/PaymentController.cs | 56 +++++++++++++++-- .../Models/DTOs/AssignmentDtos.cs | 5 ++ .../PaymentGateways/IPaymentProcessor.cs | 2 + .../SharedModels/PaymentSessionRequest.cs | 4 ++ .../Services/AssignmentService.cs | 28 ++++++++- JobFlow.Business/Services/InvoiceService.cs | 61 ++++++++++++++++++ .../ServiceInterfaces/IInvoiceService.cs | 8 +++ .../Square/SquarePaymentProcessor.cs | 6 ++ .../Stripe/StripePaymentProcessor.cs | 63 +++++++++++++++++++ .../Stripe/StripeWebhookService.cs | 51 ++++++++++++++- 10 files changed, 276 insertions(+), 8 deletions(-) diff --git a/JobFlow.API/Controllers/PaymentController.cs b/JobFlow.API/Controllers/PaymentController.cs index f90719b..b4afdc3 100644 --- a/JobFlow.API/Controllers/PaymentController.cs +++ b/JobFlow.API/Controllers/PaymentController.cs @@ -52,6 +52,7 @@ public class PaymentController : ControllerBase private readonly IStripeWebhookService _stripeWebhookService; private readonly ISubscriptionRecordService _subscriptionRecordService; private readonly IInvoiceService _invoiceService; + private readonly IJobService _jobService; private readonly IPaymentHistoryService _paymentHistoryService; private readonly IStripeSettings _stripeSettings; private readonly ISquareSettings _squareSettings; @@ -73,6 +74,7 @@ public PaymentController( IStripeWebhookService stripeWebhookService, ISquareWebhookService squareWebhookService, IInvoiceService invoiceService, + IJobService jobService, IPaymentHistoryService paymentHistoryService, IStripeSettings stripeSettings, ISquareSettings squareSettings, @@ -92,6 +94,7 @@ public PaymentController( _stripeWebhookService = stripeWebhookService; _squareWebhookService = squareWebhookService; _invoiceService = invoiceService; + _jobService = jobService; _paymentHistoryService = paymentHistoryService; _stripeSettings = stripeSettings; _squareSettings = squareSettings; @@ -203,6 +206,37 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque request.SuccessUrl = $"{feBase}/client-hub/invoices/{invoice.Id}"; } } + else if (request.InvoiceId == null && request.JobId.HasValue) + { + // No invoice provided — look up or auto-create one for the job (A+B combined). + var jobResult = await _jobService.GetJobByIdAsync(request.JobId.Value, orgId); + if (jobResult.IsFailure) + return NotFound("Job not found."); + + var invoiceResult = await _invoiceService.EnsureInvoiceForPaymentAsync( + orgId, jobResult.Value, request.Amount); + + if (invoiceResult.IsFailure) + return BadRequest(invoiceResult.Error.Description); + + var ensured = invoiceResult.Value; + request.InvoiceId = ensured.Id; + + if (!request.Amount.HasValue || request.Amount <= 0) + request.Amount = ensured.BalanceDue; + + if (request.Amount <= 0) + return BadRequest("This invoice has already been paid."); + + request.OrganizationClientId = ensured.OrganizationClientId; + request.ProductName ??= $"Invoice {ensured.InvoiceNumber}"; + + if (Enum.IsDefined(typeof(PaymentProvider), ensured.PaymentProvider) + && ensured.PaymentProvider != 0) + { + provider = ensured.PaymentProvider; + } + } IPaymentProcessor processor; try @@ -242,10 +276,22 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque { request.ConnectedAccountId = organization.StripeConnectAccountId; } - PaymentSessionResult paymentIntent; + + // Ensure success/cancel URLs are set so the hosted checkout page can redirect back. + if (string.IsNullOrWhiteSpace(request.SuccessUrl)) + { + var feBase = (_frontEndSettings.BaseUrl ?? "https://app.gojobflow.com").TrimEnd('/'); + request.SuccessUrl = $"{feBase}/payment-complete"; + request.CancelUrl ??= $"{feBase}/payment-cancelled"; + } + + PaymentSessionResult session; try { - paymentIntent = await processor.CreatePaymentIntentAsync(request); + // Use the hosted checkout flow which returns a redirect URL the mobile + // app can open directly in a browser. (CreatePaymentIntentAsync only + // returns a clientSecret, not a URL.) + session = await processor.CreateOneTimeCheckoutSessionAsync(request); } catch (InvalidOperationException ex) { @@ -254,9 +300,9 @@ public async Task Checkout([FromBody] PaymentSessionRequest reque return Ok(new { - clientSecret = paymentIntent.ClientSecret, - url = paymentIntent.RedirectUrl, - providerPaymentId = paymentIntent.ProviderPaymentId + clientSecret = session.ClientSecret, + url = session.RedirectUrl, + providerPaymentId = session.ProviderPaymentId }); } return Ok(new { url = checkoutUrl }); diff --git a/JobFlow.Business/Models/DTOs/AssignmentDtos.cs b/JobFlow.Business/Models/DTOs/AssignmentDtos.cs index 4128b66..2595368 100644 --- a/JobFlow.Business/Models/DTOs/AssignmentDtos.cs +++ b/JobFlow.Business/Models/DTOs/AssignmentDtos.cs @@ -33,6 +33,11 @@ public class AssignmentDto public string? JobTitle { get; set; } public Guid OrganizationClientId { get; set; } public string? ClientName { get; set; } + + /// Balance remaining on the associated invoice, if one exists. Null when no invoice has been raised for the job. + public decimal? BalanceDue { get; set; } + /// Invoice ID tied to the job, if one exists. + public Guid? InvoiceId { get; set; } } public class CreateAssignmentRequestDto diff --git a/JobFlow.Business/PaymentGateways/IPaymentProcessor.cs b/JobFlow.Business/PaymentGateways/IPaymentProcessor.cs index f7d6dc2..051f24a 100644 --- a/JobFlow.Business/PaymentGateways/IPaymentProcessor.cs +++ b/JobFlow.Business/PaymentGateways/IPaymentProcessor.cs @@ -5,6 +5,8 @@ namespace JobFlow.Business.PaymentGateways; public interface IPaymentProcessor { Task CreatePaymentIntentAsync(PaymentSessionRequest request); + /// Creates a hosted one-time payment checkout session and returns a redirect URL. + Task CreateOneTimeCheckoutSessionAsync(PaymentSessionRequest request); Task CreateSubscriptionCheckoutSessionAsync(PaymentSessionRequest request); Task CreateTrialSubscriptionAsync(string email, Guid orgId, string planPriceId, int trialDays = 14); } diff --git a/JobFlow.Business/PaymentGateways/SharedModels/PaymentSessionRequest.cs b/JobFlow.Business/PaymentGateways/SharedModels/PaymentSessionRequest.cs index cba467f..d0a1b51 100644 --- a/JobFlow.Business/PaymentGateways/SharedModels/PaymentSessionRequest.cs +++ b/JobFlow.Business/PaymentGateways/SharedModels/PaymentSessionRequest.cs @@ -12,6 +12,10 @@ public class PaymentSessionRequest public Guid? InvoiceId { get; set; } + /// When provided without an InvoiceId, the API will look up or auto-create + /// an invoice for this job before processing the checkout session. + public Guid? JobId { get; set; } + // Subscription-specific public string? StripePriceId { get; set; } public string? StripeCustomerId { get; set; } diff --git a/JobFlow.Business/Services/AssignmentService.cs b/JobFlow.Business/Services/AssignmentService.cs index 03e1ca5..c665e84 100644 --- a/JobFlow.Business/Services/AssignmentService.cs +++ b/JobFlow.Business/Services/AssignmentService.cs @@ -19,6 +19,7 @@ public class AssignmentService : IAssignmentService private readonly IRepository _assignmentAssignees; private readonly IRepository _employees; private readonly IRepository _jobs; + private readonly IRepository _invoices; private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly IOnboardingService _onboardingService; @@ -45,6 +46,7 @@ public AssignmentService( _assignmentAssignees = unitOfWork.RepositoryOf(); _employees = unitOfWork.RepositoryOf(); _jobs = unitOfWork.RepositoryOf(); + _invoices = unitOfWork.RepositoryOf(); _mapper = mapper; _onboardingService = onboardingService; @@ -327,8 +329,22 @@ public async Task>> GetAssignmentsAsync( if (labelMapResult.IsFailure) return Result.Failure>(labelMapResult.Error); + // Fetch the latest invoice for each job so we can surface BalanceDue on each assignment. + var jobIds = assignments.Select(a => a.JobId).ToHashSet(); + var invoicesByJobId = await _invoices.Query() + .Where(i => i.JobId.HasValue && jobIds.Contains(i.JobId.Value)) + .GroupBy(i => i.JobId!.Value) + .Select(g => g.OrderByDescending(i => i.InvoiceDate).First()) + .ToDictionaryAsync(i => i.JobId!.Value); + var labelMap = labelMapResult.Value; - var mapped = assignments.Select(a => MapToDto(a, labelMap)).ToList(); + var mapped = assignments + .Select(a => + { + invoicesByJobId.TryGetValue(a.JobId, out var inv); + return MapToDto(a, labelMap, inv?.Id, inv?.BalanceDue); + }) + .ToList(); return Result.Success(mapped); } @@ -434,7 +450,11 @@ private async Task TryCompleteJobAsync(Guid organizationId, Guid jobId) } } - private AssignmentDto MapToDto(Assignment assignment, Dictionary labelMap) + private AssignmentDto MapToDto( + Assignment assignment, + Dictionary labelMap, + Guid? invoiceId = null, + decimal? balanceDue = null) { var dto = _mapper.Map(assignment); @@ -470,6 +490,10 @@ private AssignmentDto MapToDto(Assignment assignment, Dictionary 0 ? balanceDue : null; + return dto; } diff --git a/JobFlow.Business/Services/InvoiceService.cs b/JobFlow.Business/Services/InvoiceService.cs index 53b2612..6216599 100644 --- a/JobFlow.Business/Services/InvoiceService.cs +++ b/JobFlow.Business/Services/InvoiceService.cs @@ -531,6 +531,67 @@ private async Task SendInvoiceToClientAsync(Invoice invoice) await MarkInvoiceSentAsync(invoice.Id); } + public async Task> EnsureInvoiceForPaymentAsync( + Guid organizationId, + Job job, + decimal? fallbackAmount) + { + // 1 — Return any existing invoice for the job (unpaid or paid). + var existing = await invoices.Query() + .Include(i => i.LineItems) + .FirstOrDefaultAsync(i => i.OrganizationId == organizationId && i.JobId == job.Id); + + if (existing != null) + return Result.Success(existing); + + // 2 — Option A: build invoice from an accepted estimate. + var fromEstimate = await CreateInvoiceFromEstimateAsync(organizationId, job); + if (fromEstimate.IsSuccess) + return fromEstimate; + + // 3 — Option B: no estimate — create a quick single-line-item draft invoice. + if (!fallbackAmount.HasValue || fallbackAmount.Value <= 0) + return Result.Failure( + Error.Validation("Invoice.AmountRequired", + "No invoice or accepted estimate found for this job. Please provide a payment amount.")); + + var client = await clients.Query() + .FirstOrDefaultAsync(c => c.Id == job.OrganizationClientId); + + if (client == null) + return Result.Failure(EstimateErrors.ClientNotFound); + + var quickInvoice = new Invoice + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + OrganizationClientId = job.OrganizationClientId, + JobId = job.Id, + InvoiceNumber = await _numberGenerator.GenerateAsync(organizationId), + InvoiceDate = DateTime.UtcNow, + DueDate = DateTime.UtcNow.AddDays(14), + Status = InvoiceStatus.Draft, + OrganizationClient = client, + LineItems = + [ + new InvoiceLineItem + { + Id = Guid.NewGuid(), + Description = string.IsNullOrWhiteSpace(job.Title) ? "Services rendered" : job.Title, + Quantity = 1, + UnitPrice = fallbackAmount.Value, + } + ], + }; + + var saved = await UpsertInvoiceAsync(quickInvoice); + if (saved.IsFailure) + return Result.Failure(saved.Error); + + // Return the hydrated version so BalanceDue is computed. + return await GetInvoiceByIdAsync(saved.Value.Id); + } + private async Task> CreateInvoiceFromEstimateAsync(Guid organizationId, Job job) { Estimate? estimate = null; diff --git a/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs b/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs index 7b8b1f2..23b8784 100644 --- a/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs +++ b/JobFlow.Business/Services/ServiceInterfaces/IInvoiceService.cs @@ -26,6 +26,14 @@ Task>> GetInvoicesByOrganizationPagedAsyn Task SendInvoiceToClientAsync(Guid invoiceId); Task SendInvoiceForJobAsync(Guid organizationId, Job job); + /// + /// Finds an existing invoice for the job, or creates one. + /// Creation first tries to build from an accepted estimate (Option A). + /// If no estimate exists and is provided, + /// a single-line-item draft invoice is created for that amount (Option B). + /// + Task> EnsureInvoiceForPaymentAsync(Guid organizationId, Job job, decimal? fallbackAmount); + Task> MarkPaidAsync( Guid invoiceId, PaymentProvider provider, diff --git a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs index a738dfa..1bbffcd 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs @@ -134,6 +134,12 @@ public Task CreatePaymentIntentAsync(PaymentSessionRequest return CreateCheckoutIntentAsync(request); } + // Square already uses a hosted checkout page for all payments. + public Task CreateOneTimeCheckoutSessionAsync(PaymentSessionRequest request) + { + return CreateCheckoutIntentAsync(request); + } + public Task CreateSubscriptionCheckoutSessionAsync(PaymentSessionRequest request) { return CreateCheckoutSessionAsync(request); diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs index de920fe..b14532c 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs @@ -120,6 +120,69 @@ public async Task CreatePaymentIntentAsync( }; } + /// + /// Creates a Stripe Checkout Session (hosted payment page) for a one-time payment. + /// Returns a whose RedirectUrl points to the + /// Stripe-hosted checkout page. The caller opens this URL in a browser or web-view. + /// + public async Task CreateOneTimeCheckoutSessionAsync(PaymentSessionRequest request) + { + if (string.IsNullOrWhiteSpace(request.ConnectedAccountId)) + throw new InvalidOperationException("Connected account is required."); + + var amountInCents = + request.Amount?.ToCents() + ?? throw new InvalidOperationException("Payment amount is required."); + + long applicationFee = _paymentSettings.ApplicationFee.ToCents(); + + var options = new SessionCreateOptions + { + Mode = "payment", + LineItems = new List + { + new() + { + PriceData = new SessionLineItemPriceDataOptions + { + Currency = "usd", + UnitAmount = amountInCents, + ProductData = new SessionLineItemPriceDataProductDataOptions + { + Name = request.ProductName ?? "Service", + }, + }, + Quantity = request.Quantity ?? 1, + } + }, + PaymentIntentData = new SessionPaymentIntentDataOptions + { + ApplicationFeeAmount = applicationFee, + TransferData = new SessionPaymentIntentDataTransferDataOptions + { + Destination = request.ConnectedAccountId, + }, + Metadata = request.InvoiceId.HasValue + ? new Dictionary { { "invoiceId", request.InvoiceId.Value.ToString() } } + : null, + }, + SuccessUrl = request.SuccessUrl, + CancelUrl = request.CancelUrl, + Metadata = request.InvoiceId.HasValue + ? new Dictionary { { "invoiceId", request.InvoiceId.Value.ToString() } } + : null, + }; + + var sessionService = new SessionService(); + var session = await sessionService.CreateAsync(options); + + return new PaymentSessionResult + { + RedirectUrl = session.Url, + ProviderPaymentId = session.Id, + }; + } + public async Task CreateDepositPaymentAsync(PaymentSessionRequest request) { request.Amount = request.DepositAmount ?? request.Amount; diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs index 9f85490..de3e771 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripeWebhookService.cs @@ -281,12 +281,61 @@ await HandleLedgerEventAsync( private async Task HandleCheckoutSessionAsync(Session session) { + // ── One-time payment session (mode: "payment") ────────────────────────── + // These sessions have no SubscriptionId. If metadata contains an invoiceId + // we mark the matching JobFlow invoice as paid. if (string.IsNullOrWhiteSpace(session.SubscriptionId)) { - _logger.LogWarning("Stripe checkout.session.completed has no SubscriptionId. SessionId={SessionId}", session.Id); + if (session.Metadata != null + && session.Metadata.TryGetValue("invoiceId", out var invoiceIdRaw) + && Guid.TryParse(invoiceIdRaw, out var invoiceId)) + { + // AmountTotal is in cents; convert to dollars. + var amountReceived = (session.AmountTotal ?? 0) / 100m; + var providerPaymentId = session.PaymentIntentId ?? session.Id; + + var markResult = await _invoiceService.MarkPaidAsync( + invoiceId, + PaymentProvider.Stripe, + providerPaymentId, + amountReceived); + + if (markResult.IsFailure) + { + _logger.LogWarning( + "MarkPaidAsync failed for invoiceId={InvoiceId}, SessionId={SessionId}: {Error}", + invoiceId, session.Id, markResult.Error.Description); + } + else + { + _logger.LogInformation( + "Invoice {InvoiceId} marked paid via Stripe checkout session {SessionId}, amount={Amount}", + invoiceId, session.Id, amountReceived); + + var paidInvoice = markResult.Value; + await _onboardingService.MarkStepCompleteAsync( + paidInvoice.OrganizationClient.OrganizationId, + OnboardingStepKeys.ReceivePayment); + await _notificationService.SendClientPaymentReceivedNotificationAsync( + paidInvoice.OrganizationClient, paidInvoice); + await _notificationService.SendOrganizationInvoicePaymentReceivedNotificationAsync( + paidInvoice.OrganizationClient.Organization, + paidInvoice.OrganizationClient, + paidInvoice, + amountReceived); + } + } + else + { + _logger.LogWarning( + "Stripe checkout.session.completed has no SubscriptionId and no invoiceId metadata. SessionId={SessionId}", + session.Id); + } + return; } + // ── Subscription checkout session ──────────────────────────────────────── var subscriptionService = new SubscriptionService(); var subscription = await subscriptionService.GetAsync(session.SubscriptionId);