Skip to content

Commit b2af73f

Browse files
[PM-212] Sync Organization Billing Email from Stripe Webhook (#3305)
* Add StripeFacade and StripeEventService * Add StripeEventServiceTests * Handle customer.updated event in StripeController
1 parent 3a71e7b commit b2af73f

19 files changed

+2309
-207
lines changed

src/Billing/Constants/HandledStripeWebhook.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ public static class HandledStripeWebhook
1111
public const string PaymentFailed = "invoice.payment_failed";
1212
public const string InvoiceCreated = "invoice.created";
1313
public const string PaymentMethodAttached = "payment_method.attached";
14+
public const string CustomerUpdated = "customer.updated";
1415
}

src/Billing/Controllers/StripeController.cs

Lines changed: 36 additions & 207 deletions
Large diffs are not rendered by default.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using Stripe;
2+
3+
namespace Bit.Billing.Services;
4+
5+
public interface IStripeEventService
6+
{
7+
/// <summary>
8+
/// Extracts the <see cref="Charge"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
9+
/// uses the charge ID extracted from the event to retrieve the most up-to-update charge from Stripe's API
10+
/// and optionally expands it with the provided <see cref="expand"/> options.
11+
/// </summary>
12+
/// <param name="stripeEvent">The Stripe webhook event.</param>
13+
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the charge object from Stripe.</param>
14+
/// <param name="expand">Optionally provided to expand the fresh charge object retrieved from Stripe.</param>
15+
/// <returns>A Stripe <see cref="Charge"/>.</returns>
16+
/// <exception cref="Exception">Thrown when the Stripe event does not contain a charge object.</exception>
17+
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null charge object.</exception>
18+
Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null);
19+
20+
/// <summary>
21+
/// Extracts the <see cref="Customer"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
22+
/// uses the customer ID extracted from the event to retrieve the most up-to-update customer from Stripe's API
23+
/// and optionally expands it with the provided <see cref="expand"/> options.
24+
/// </summary>
25+
/// <param name="stripeEvent">The Stripe webhook event.</param>
26+
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the customer object from Stripe.</param>
27+
/// <param name="expand">Optionally provided to expand the fresh customer object retrieved from Stripe.</param>
28+
/// <returns>A Stripe <see cref="Customer"/>.</returns>
29+
/// <exception cref="Exception">Thrown when the Stripe event does not contain a customer object.</exception>
30+
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null customer object.</exception>
31+
Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null);
32+
33+
/// <summary>
34+
/// Extracts the <see cref="Invoice"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
35+
/// uses the invoice ID extracted from the event to retrieve the most up-to-update invoice from Stripe's API
36+
/// and optionally expands it with the provided <see cref="expand"/> options.
37+
/// </summary>
38+
/// <param name="stripeEvent">The Stripe webhook event.</param>
39+
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the invoice object from Stripe.</param>
40+
/// <param name="expand">Optionally provided to expand the fresh invoice object retrieved from Stripe.</param>
41+
/// <returns>A Stripe <see cref="Invoice"/>.</returns>
42+
/// <exception cref="Exception">Thrown when the Stripe event does not contain an invoice object.</exception>
43+
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null invoice object.</exception>
44+
Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null);
45+
46+
/// <summary>
47+
/// Extracts the <see cref="PaymentMethod"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
48+
/// uses the payment method ID extracted from the event to retrieve the most up-to-update payment method from Stripe's API
49+
/// and optionally expands it with the provided <see cref="expand"/> options.
50+
/// </summary>
51+
/// <param name="stripeEvent">The Stripe webhook event.</param>
52+
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the payment method object from Stripe.</param>
53+
/// <param name="expand">Optionally provided to expand the fresh payment method object retrieved from Stripe.</param>
54+
/// <returns>A Stripe <see cref="PaymentMethod"/>.</returns>
55+
/// <exception cref="Exception">Thrown when the Stripe event does not contain an payment method object.</exception>
56+
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null payment method object.</exception>
57+
Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null);
58+
59+
/// <summary>
60+
/// Extracts the <see cref="Subscription"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
61+
/// uses the subscription ID extracted from the event to retrieve the most up-to-update subscription from Stripe's API
62+
/// and optionally expands it with the provided <see cref="expand"/> options.
63+
/// </summary>
64+
/// <param name="stripeEvent">The Stripe webhook event.</param>
65+
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the subscription object from Stripe.</param>
66+
/// <param name="expand">Optionally provided to expand the fresh subscription object retrieved from Stripe.</param>
67+
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
68+
/// <exception cref="Exception">Thrown when the Stripe event does not contain an subscription object.</exception>
69+
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null subscription object.</exception>
70+
Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null);
71+
72+
/// <summary>
73+
/// Ensures that the customer associated with the Stripe <see cref="Event"/> is in the correct region for this server.
74+
/// We use the customer instead of the subscription given that all subscriptions have customers, but not all
75+
/// customers have subscriptions.
76+
/// </summary>
77+
/// <param name="stripeEvent">The Stripe webhook event.</param>
78+
/// <returns>True if the customer's region and the server's region match, otherwise false.</returns>
79+
Task<bool> ValidateCloudRegion(Event stripeEvent);
80+
}

src/Billing/Services/IStripeFacade.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using Stripe;
2+
3+
namespace Bit.Billing.Services;
4+
5+
public interface IStripeFacade
6+
{
7+
Task<Charge> GetCharge(
8+
string chargeId,
9+
ChargeGetOptions chargeGetOptions = null,
10+
RequestOptions requestOptions = null,
11+
CancellationToken cancellationToken = default);
12+
13+
Task<Customer> GetCustomer(
14+
string customerId,
15+
CustomerGetOptions customerGetOptions = null,
16+
RequestOptions requestOptions = null,
17+
CancellationToken cancellationToken = default);
18+
19+
Task<Invoice> GetInvoice(
20+
string invoiceId,
21+
InvoiceGetOptions invoiceGetOptions = null,
22+
RequestOptions requestOptions = null,
23+
CancellationToken cancellationToken = default);
24+
25+
Task<PaymentMethod> GetPaymentMethod(
26+
string paymentMethodId,
27+
PaymentMethodGetOptions paymentMethodGetOptions = null,
28+
RequestOptions requestOptions = null,
29+
CancellationToken cancellationToken = default);
30+
31+
Task<Subscription> GetSubscription(
32+
string subscriptionId,
33+
SubscriptionGetOptions subscriptionGetOptions = null,
34+
RequestOptions requestOptions = null,
35+
CancellationToken cancellationToken = default);
36+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
using Bit.Billing.Constants;
2+
using Bit.Core.Settings;
3+
using Stripe;
4+
5+
namespace Bit.Billing.Services.Implementations;
6+
7+
public class StripeEventService : IStripeEventService
8+
{
9+
private readonly GlobalSettings _globalSettings;
10+
private readonly IStripeFacade _stripeFacade;
11+
12+
public StripeEventService(
13+
GlobalSettings globalSettings,
14+
IStripeFacade stripeFacade)
15+
{
16+
_globalSettings = globalSettings;
17+
_stripeFacade = stripeFacade;
18+
}
19+
20+
public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null)
21+
{
22+
var eventCharge = Extract<Charge>(stripeEvent);
23+
24+
if (!fresh)
25+
{
26+
return eventCharge;
27+
}
28+
29+
var charge = await _stripeFacade.GetCharge(eventCharge.Id, new ChargeGetOptions { Expand = expand });
30+
31+
if (charge == null)
32+
{
33+
throw new Exception(
34+
$"Received null Charge from Stripe for ID '{eventCharge.Id}' while processing Event with ID '{stripeEvent.Id}'");
35+
}
36+
37+
return charge;
38+
}
39+
40+
public async Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null)
41+
{
42+
var eventCustomer = Extract<Customer>(stripeEvent);
43+
44+
if (!fresh)
45+
{
46+
return eventCustomer;
47+
}
48+
49+
var customer = await _stripeFacade.GetCustomer(eventCustomer.Id, new CustomerGetOptions { Expand = expand });
50+
51+
if (customer == null)
52+
{
53+
throw new Exception(
54+
$"Received null Customer from Stripe for ID '{eventCustomer.Id}' while processing Event with ID '{stripeEvent.Id}'");
55+
}
56+
57+
return customer;
58+
}
59+
60+
public async Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null)
61+
{
62+
var eventInvoice = Extract<Invoice>(stripeEvent);
63+
64+
if (!fresh)
65+
{
66+
return eventInvoice;
67+
}
68+
69+
var invoice = await _stripeFacade.GetInvoice(eventInvoice.Id, new InvoiceGetOptions { Expand = expand });
70+
71+
if (invoice == null)
72+
{
73+
throw new Exception(
74+
$"Received null Invoice from Stripe for ID '{eventInvoice.Id}' while processing Event with ID '{stripeEvent.Id}'");
75+
}
76+
77+
return invoice;
78+
}
79+
80+
public async Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null)
81+
{
82+
var eventPaymentMethod = Extract<PaymentMethod>(stripeEvent);
83+
84+
if (!fresh)
85+
{
86+
return eventPaymentMethod;
87+
}
88+
89+
var paymentMethod = await _stripeFacade.GetPaymentMethod(eventPaymentMethod.Id, new PaymentMethodGetOptions { Expand = expand });
90+
91+
if (paymentMethod == null)
92+
{
93+
throw new Exception(
94+
$"Received null Payment Method from Stripe for ID '{eventPaymentMethod.Id}' while processing Event with ID '{stripeEvent.Id}'");
95+
}
96+
97+
return paymentMethod;
98+
}
99+
100+
public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null)
101+
{
102+
var eventSubscription = Extract<Subscription>(stripeEvent);
103+
104+
if (!fresh)
105+
{
106+
return eventSubscription;
107+
}
108+
109+
var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand });
110+
111+
if (subscription == null)
112+
{
113+
throw new Exception(
114+
$"Received null Subscription from Stripe for ID '{eventSubscription.Id}' while processing Event with ID '{stripeEvent.Id}'");
115+
}
116+
117+
return subscription;
118+
}
119+
120+
public async Task<bool> ValidateCloudRegion(Event stripeEvent)
121+
{
122+
var serverRegion = _globalSettings.BaseServiceUri.CloudRegion;
123+
124+
var customerExpansion = new List<string> { "customer" };
125+
126+
var customerMetadata = stripeEvent.Type switch
127+
{
128+
HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated =>
129+
(await GetSubscription(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
130+
131+
HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded =>
132+
(await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
133+
134+
HandledStripeWebhook.UpcomingInvoice =>
135+
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
136+
137+
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated =>
138+
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
139+
140+
HandledStripeWebhook.PaymentMethodAttached =>
141+
(await GetPaymentMethod(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
142+
143+
HandledStripeWebhook.CustomerUpdated =>
144+
(await GetCustomer(stripeEvent, true))?.Metadata,
145+
146+
_ => null
147+
};
148+
149+
if (customerMetadata == null)
150+
{
151+
return false;
152+
}
153+
154+
var customerRegion = GetCustomerRegion(customerMetadata);
155+
156+
return customerRegion == serverRegion;
157+
}
158+
159+
private static T Extract<T>(Event stripeEvent)
160+
{
161+
if (stripeEvent.Data.Object is not T type)
162+
{
163+
throw new Exception($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'");
164+
}
165+
166+
return type;
167+
}
168+
169+
private static string GetCustomerRegion(IDictionary<string, string> customerMetadata)
170+
{
171+
const string defaultRegion = "US";
172+
173+
if (customerMetadata is null)
174+
{
175+
return null;
176+
}
177+
178+
if (customerMetadata.TryGetValue("region", out var value))
179+
{
180+
return value;
181+
}
182+
183+
var miscasedRegionKey = customerMetadata.Keys
184+
.FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase));
185+
186+
if (miscasedRegionKey is null)
187+
{
188+
return defaultRegion;
189+
}
190+
191+
_ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue);
192+
193+
return !string.IsNullOrWhiteSpace(regionValue)
194+
? regionValue
195+
: defaultRegion;
196+
}
197+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using Stripe;
2+
3+
namespace Bit.Billing.Services.Implementations;
4+
5+
public class StripeFacade : IStripeFacade
6+
{
7+
private readonly ChargeService _chargeService = new();
8+
private readonly CustomerService _customerService = new();
9+
private readonly InvoiceService _invoiceService = new();
10+
private readonly PaymentMethodService _paymentMethodService = new();
11+
private readonly SubscriptionService _subscriptionService = new();
12+
13+
public async Task<Charge> GetCharge(
14+
string chargeId,
15+
ChargeGetOptions chargeGetOptions = null,
16+
RequestOptions requestOptions = null,
17+
CancellationToken cancellationToken = default) =>
18+
await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken);
19+
20+
public async Task<Customer> GetCustomer(
21+
string customerId,
22+
CustomerGetOptions customerGetOptions = null,
23+
RequestOptions requestOptions = null,
24+
CancellationToken cancellationToken = default) =>
25+
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
26+
27+
public async Task<Invoice> GetInvoice(
28+
string invoiceId,
29+
InvoiceGetOptions invoiceGetOptions = null,
30+
RequestOptions requestOptions = null,
31+
CancellationToken cancellationToken = default) =>
32+
await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken);
33+
34+
public async Task<PaymentMethod> GetPaymentMethod(
35+
string paymentMethodId,
36+
PaymentMethodGetOptions paymentMethodGetOptions = null,
37+
RequestOptions requestOptions = null,
38+
CancellationToken cancellationToken = default) =>
39+
await _paymentMethodService.GetAsync(paymentMethodId, paymentMethodGetOptions, requestOptions, cancellationToken);
40+
41+
public async Task<Subscription> GetSubscription(
42+
string subscriptionId,
43+
SubscriptionGetOptions subscriptionGetOptions = null,
44+
RequestOptions requestOptions = null,
45+
CancellationToken cancellationToken = default) =>
46+
await _subscriptionService.GetAsync(subscriptionId, subscriptionGetOptions, requestOptions, cancellationToken);
47+
}

src/Billing/Startup.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System.Globalization;
2+
using Bit.Billing.Services;
3+
using Bit.Billing.Services.Implementations;
24
using Bit.Core.Context;
35
using Bit.Core.SecretsManager.Repositories;
46
using Bit.Core.SecretsManager.Repositories.Noop;
@@ -80,6 +82,9 @@ public void ConfigureServices(IServiceCollection services)
8082

8183
// Set up HttpClients
8284
services.AddHttpClient("FreshdeskApi");
85+
86+
services.AddScoped<IStripeFacade, StripeFacade>();
87+
services.AddScoped<IStripeEventService, StripeEventService>();
8388
}
8489

8590
public void Configure(

src/Core/Tools/Enums/ReferenceEventType.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ public enum ReferenceEventType
4242
OrganizationEditedByAdmin,
4343
[EnumMember(Value = "organization-created-by-admin")]
4444
OrganizationCreatedByAdmin,
45+
[EnumMember(Value = "organization-edited-in-stripe")]
46+
OrganizationEditedInStripe,
4547
[EnumMember(Value = "sm-service-account-accessed-secret")]
4648
SmServiceAccountAccessedSecret,
4749
}

0 commit comments

Comments
 (0)