From 1e6231d046abed3b8ce3b72a92f7002905f13f2c Mon Sep 17 00:00:00 2001 From: Jerry Phillips Date: Thu, 7 May 2026 22:33:25 -0400 Subject: [PATCH] feat(org): [AB#160] trial stripe subscription on signup Changes: - Add CreateTrialSubscriptionAsync to IPaymentProcessor interface - Add ProviderCustomerId field to PaymentOperationResult - Implement CreateTrialSubscriptionAsync in StripePaymentProcessor: creates Stripe customer + subscription with 14-day TrialEnd - Add NotSupportedException stub to SquarePaymentProcessor - OrganizationController.RegisterOrganization now provisions a Go-plan trial subscription, creates CustomerPaymentProfile and SubscriptionRecord, and sets org SubscriptionStatus=trialing References: AB#160 --- .../Controllers/OrganizationController.cs | 45 +++++++++++++++++ .../PaymentGateways/IPaymentProcessor.cs | 1 + .../SharedModels/PaymentOperationResult.cs | 1 + .../Square/SquarePaymentProcessor.cs | 5 ++ .../Stripe/StripePaymentProcessor.cs | 50 +++++++++++++++++++ 5 files changed, 102 insertions(+) diff --git a/JobFlow.API/Controllers/OrganizationController.cs b/JobFlow.API/Controllers/OrganizationController.cs index 050ee43..41ce4c8 100644 --- a/JobFlow.API/Controllers/OrganizationController.cs +++ b/JobFlow.API/Controllers/OrganizationController.cs @@ -4,9 +4,11 @@ using JobFlow.API.Models; using JobFlow.Business.Extensions; using JobFlow.Business.Models.DTOs; +using JobFlow.Business.PaymentGateways; using JobFlow.Business.Services.ServiceInterfaces; using JobFlow.Domain.Enums; using JobFlow.Domain.Models; +using JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,6 +21,9 @@ public class OrganizationController : ControllerBase private readonly IOrganizationBrandingService _organizationBrandingService; private readonly IOrganizationService _organizationService; private readonly IPaymentProfileService _paymentProfileService; + private readonly ISubscriptionRecordService _subscriptionRecordService; + private readonly IPaymentProcessor _paymentProcessor; + private readonly IStripeSettings _stripeSettings; private readonly IUserService _userService; private readonly IEmployeeService _employeeService; private readonly IEmployeeRoleService _employeeRoleService; @@ -28,6 +33,9 @@ public OrganizationController( IOrganizationService organizationService, IUserService userService, IPaymentProfileService paymentProfileService, + ISubscriptionRecordService subscriptionRecordService, + IPaymentProcessor paymentProcessor, + IStripeSettings stripeSettings, IOrganizationBrandingService organizationBrandingService, IEmployeeService employeeService, IEmployeeRoleService employeeRoleService, @@ -37,6 +45,9 @@ ILogger logger _organizationService = organizationService; _userService = userService; _paymentProfileService = paymentProfileService; + _subscriptionRecordService = subscriptionRecordService; + _paymentProcessor = paymentProcessor; + _stripeSettings = stripeSettings; _organizationBrandingService = organizationBrandingService; _employeeService = employeeService; _employeeRoleService = employeeRoleService; @@ -129,6 +140,40 @@ await FirebaseAuth.DefaultInstance.UpdateUserAsync(new UserRecordArgs if (!string.IsNullOrWhiteSpace(model.OrgSize)) await _organizationService.SetOrgSizeAsync(model.Id.Value, model.OrgSize); + // Provision a 14-day free trial subscription on the Go plan (no payment method required) + var trialResult = await _paymentProcessor.CreateTrialSubscriptionAsync( + model.EmailAddress!, model.Id.Value, _stripeSettings.GoMonthlyPrice); + + if (trialResult.Success + && !string.IsNullOrWhiteSpace(trialResult.ProviderCustomerId) + && !string.IsNullOrWhiteSpace(trialResult.ProviderPaymentId)) + { + var profileResult = await _paymentProfileService.CreateAsync( + model.Id.Value, PaymentEntityType.Organization, PaymentProvider.Stripe, trialResult.ProviderCustomerId!); + + if (profileResult.IsSuccess) + { + await _subscriptionRecordService.CreateAsync( + profileResult.Value.Id, + trialResult.ProviderPaymentId!, + trialResult.ProviderPriceId ?? _stripeSettings.GoMonthlyPrice, + "trialing", + trialResult.SubscriptionPlanName ?? "Go"); + } + + await _organizationService.UpdateSubscriptionStateAsync( + model.Id.Value, + "trialing", + trialResult.SubscriptionPlanName ?? "Go", + trialResult.SubscriptionExpiresAtUtc); + } + else + { + _logger.LogWarning( + "Trial Stripe subscription was not created for OrgId={OrganizationId}. Message={Message}", + model.Id.Value, trialResult.Message); + } + var orgResults = await _organizationService.GetOrganizationDtoById(model.Id.Value); return orgResults.IsSuccess ? Results.Ok(orgResults.Value) : orgResults.ToProblemDetails(); } diff --git a/JobFlow.Business/PaymentGateways/IPaymentProcessor.cs b/JobFlow.Business/PaymentGateways/IPaymentProcessor.cs index 4d54aac..f7d6dc2 100644 --- a/JobFlow.Business/PaymentGateways/IPaymentProcessor.cs +++ b/JobFlow.Business/PaymentGateways/IPaymentProcessor.cs @@ -6,6 +6,7 @@ public interface IPaymentProcessor { Task CreatePaymentIntentAsync(PaymentSessionRequest request); Task CreateSubscriptionCheckoutSessionAsync(PaymentSessionRequest request); + Task CreateTrialSubscriptionAsync(string email, Guid orgId, string planPriceId, int trialDays = 14); } public interface IPaymentOperationsProcessor diff --git a/JobFlow.Business/PaymentGateways/SharedModels/PaymentOperationResult.cs b/JobFlow.Business/PaymentGateways/SharedModels/PaymentOperationResult.cs index 83d02c8..59f5330 100644 --- a/JobFlow.Business/PaymentGateways/SharedModels/PaymentOperationResult.cs +++ b/JobFlow.Business/PaymentGateways/SharedModels/PaymentOperationResult.cs @@ -4,6 +4,7 @@ public class PaymentOperationResult { public bool Success { get; set; } public string? ProviderPaymentId { get; set; } + public string? ProviderCustomerId { get; set; } public string? Message { get; set; } public decimal? Amount { get; set; } public string? Currency { get; set; } diff --git a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs index 92bac10..8476141 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Square/SquarePaymentProcessor.cs @@ -139,6 +139,11 @@ public Task CreateSubscriptionCheckoutSessionAsync(PaymentSessionRequest return CreateCheckoutSessionAsync(request); } + public Task CreateTrialSubscriptionAsync(string email, Guid orgId, string planPriceId, int trialDays = 14) + { + throw new NotSupportedException("Trial subscriptions are not supported by the Square payment processor."); + } + public Task CreateDepositPaymentAsync(PaymentSessionRequest request) { request.Amount = request.DepositAmount ?? request.Amount; diff --git a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs index 0529490..6776a5f 100644 --- a/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs +++ b/JobFlow.Infrastructure/PaymentGateways/Stripe/StripePaymentProcessor.cs @@ -335,6 +335,56 @@ public async Task CreateStripeCustomerAsync(string email) return customer.Id; } + public async Task CreateTrialSubscriptionAsync(string email, Guid orgId, string planPriceId, int trialDays = 14) + { + if (string.IsNullOrWhiteSpace(email)) + throw new InvalidOperationException("Customer email is required for trial subscription."); + + if (orgId == Guid.Empty) + throw new InvalidOperationException("Organization id is required for trial subscription."); + + if (string.IsNullOrWhiteSpace(planPriceId)) + throw new InvalidOperationException("Plan price id is required for trial subscription."); + + var customerId = await CreateStripeCustomerAsync(email); + var ownerId = orgId.ToString(); + + var subscriptionService = new SubscriptionService(); + var subscription = await subscriptionService.CreateAsync(new SubscriptionCreateOptions + { + Customer = customerId, + Items = new List + { + new() { Price = planPriceId.Trim() } + }, + TrialEnd = DateTime.UtcNow.AddDays(trialDays), + Metadata = new Dictionary + { + { "ownerId", ownerId }, + { "ownerType", PaymentEntityType.Organization.ToString() } + } + }); + + return new PaymentOperationResult + { + Success = !string.IsNullOrWhiteSpace(subscription.Id), + ProviderPaymentId = subscription.Id, + ProviderCustomerId = customerId, + ProviderPriceId = planPriceId.Trim(), + SubscriptionStatus = subscription.Status, + SubscriptionPlanName = ResolvePlanName(planPriceId), + SubscriptionExpiresAtUtc = subscription.TrialEnd?.ToUniversalTime() + }; + } + + private string ResolvePlanName(string priceId) + { + if (priceId == _stripeSettings.GoMonthlyPrice || priceId == _stripeSettings.GoYearlyPrice) return "Go"; + if (priceId == _stripeSettings.FlowMonthlyPrice || priceId == _stripeSettings.FlowYearlyPrice) return "Flow"; + if (priceId == _stripeSettings.MaxMonthlyPrice || priceId == _stripeSettings.MaxYearlyPrice) return "Max"; + return priceId; + } + private static string BuildSubscriptionSuccessUrl( string? baseUrl, string organizationId,