Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 44 additions & 6 deletions JobFlow.API/Controllers/OrganizationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -29,6 +34,9 @@ public OrganizationController(
IOrganizationService organizationService,
IUserService userService,
IPaymentProfileService paymentProfileService,
ISubscriptionRecordService subscriptionRecordService,
IPaymentProcessor paymentProcessor,
IStripeSettings stripeSettings,
IOrganizationBrandingService organizationBrandingService,
IEmployeeService employeeService,
IEmployeeRoleService employeeRoleService,
Expand All @@ -39,6 +47,9 @@ ILogger<OrganizationController> logger
_organizationService = organizationService;
_userService = userService;
_paymentProfileService = paymentProfileService;
_subscriptionRecordService = subscriptionRecordService;
_paymentProcessor = paymentProcessor;
_stripeSettings = stripeSettings;
_organizationBrandingService = organizationBrandingService;
_employeeService = employeeService;
_employeeRoleService = employeeRoleService;
Expand Down Expand Up @@ -132,12 +143,39 @@ 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 for every new org
await _organizationService.UpdateSubscriptionStateAsync(
model.Id.Value,
subscriptionStatus: "Trialing",
subscriptionPlanName: "Go",
subscriptionExpiresAt: DateTime.UtcNow.AddDays(14));
// 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);
}

// Enroll in Brevo trial drip sequence (fire-and-forget; failure is non-fatal)
var orgId = model.Id.Value;
Expand Down
1 change: 1 addition & 0 deletions JobFlow.Business/PaymentGateways/IPaymentProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public interface IPaymentProcessor
{
Task<PaymentSessionResult> CreatePaymentIntentAsync(PaymentSessionRequest request);
Task<string> CreateSubscriptionCheckoutSessionAsync(PaymentSessionRequest request);
Task<PaymentOperationResult> CreateTrialSubscriptionAsync(string email, Guid orgId, string planPriceId, int trialDays = 14);
}

public interface IPaymentOperationsProcessor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ public Task<string> CreateSubscriptionCheckoutSessionAsync(PaymentSessionRequest
return CreateCheckoutSessionAsync(request);
}

public Task<PaymentOperationResult> 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<PaymentSessionResult> CreateDepositPaymentAsync(PaymentSessionRequest request)
{
request.Amount = request.DepositAmount ?? request.Amount;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,56 @@ public async Task<string> CreateStripeCustomerAsync(string email)
return customer.Id;
}

public async Task<PaymentOperationResult> 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<SubscriptionItemOptions>
{
new() { Price = planPriceId.Trim() }
},
TrialEnd = DateTime.UtcNow.AddDays(trialDays),
Metadata = new Dictionary<string, string>
{
{ "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,
Expand Down
Loading