Browse Source
* Add StripeFacade and StripeEventService * Add StripeEventServiceTests * Handle customer.updated event in StripeControllerpull/3341/head
19 changed files with 2309 additions and 207 deletions
@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
|
||||
using Stripe; |
||||
|
||||
namespace Bit.Billing.Services; |
||||
|
||||
public interface IStripeEventService |
||||
{ |
||||
/// <summary> |
||||
/// Extracts the <see cref="Charge"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true, |
||||
/// uses the charge ID extracted from the event to retrieve the most up-to-update charge from Stripe's API |
||||
/// and optionally expands it with the provided <see cref="expand"/> options. |
||||
/// </summary> |
||||
/// <param name="stripeEvent">The Stripe webhook event.</param> |
||||
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the charge object from Stripe.</param> |
||||
/// <param name="expand">Optionally provided to expand the fresh charge object retrieved from Stripe.</param> |
||||
/// <returns>A Stripe <see cref="Charge"/>.</returns> |
||||
/// <exception cref="Exception">Thrown when the Stripe event does not contain a charge object.</exception> |
||||
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null charge object.</exception> |
||||
Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null); |
||||
|
||||
/// <summary> |
||||
/// Extracts the <see cref="Customer"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true, |
||||
/// uses the customer ID extracted from the event to retrieve the most up-to-update customer from Stripe's API |
||||
/// and optionally expands it with the provided <see cref="expand"/> options. |
||||
/// </summary> |
||||
/// <param name="stripeEvent">The Stripe webhook event.</param> |
||||
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the customer object from Stripe.</param> |
||||
/// <param name="expand">Optionally provided to expand the fresh customer object retrieved from Stripe.</param> |
||||
/// <returns>A Stripe <see cref="Customer"/>.</returns> |
||||
/// <exception cref="Exception">Thrown when the Stripe event does not contain a customer object.</exception> |
||||
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null customer object.</exception> |
||||
Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null); |
||||
|
||||
/// <summary> |
||||
/// Extracts the <see cref="Invoice"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true, |
||||
/// uses the invoice ID extracted from the event to retrieve the most up-to-update invoice from Stripe's API |
||||
/// and optionally expands it with the provided <see cref="expand"/> options. |
||||
/// </summary> |
||||
/// <param name="stripeEvent">The Stripe webhook event.</param> |
||||
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the invoice object from Stripe.</param> |
||||
/// <param name="expand">Optionally provided to expand the fresh invoice object retrieved from Stripe.</param> |
||||
/// <returns>A Stripe <see cref="Invoice"/>.</returns> |
||||
/// <exception cref="Exception">Thrown when the Stripe event does not contain an invoice object.</exception> |
||||
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null invoice object.</exception> |
||||
Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null); |
||||
|
||||
/// <summary> |
||||
/// Extracts the <see cref="PaymentMethod"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true, |
||||
/// uses the payment method ID extracted from the event to retrieve the most up-to-update payment method from Stripe's API |
||||
/// and optionally expands it with the provided <see cref="expand"/> options. |
||||
/// </summary> |
||||
/// <param name="stripeEvent">The Stripe webhook event.</param> |
||||
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the payment method object from Stripe.</param> |
||||
/// <param name="expand">Optionally provided to expand the fresh payment method object retrieved from Stripe.</param> |
||||
/// <returns>A Stripe <see cref="PaymentMethod"/>.</returns> |
||||
/// <exception cref="Exception">Thrown when the Stripe event does not contain an payment method object.</exception> |
||||
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null payment method object.</exception> |
||||
Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null); |
||||
|
||||
/// <summary> |
||||
/// Extracts the <see cref="Subscription"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true, |
||||
/// uses the subscription ID extracted from the event to retrieve the most up-to-update subscription from Stripe's API |
||||
/// and optionally expands it with the provided <see cref="expand"/> options. |
||||
/// </summary> |
||||
/// <param name="stripeEvent">The Stripe webhook event.</param> |
||||
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the subscription object from Stripe.</param> |
||||
/// <param name="expand">Optionally provided to expand the fresh subscription object retrieved from Stripe.</param> |
||||
/// <returns>A Stripe <see cref="Subscription"/>.</returns> |
||||
/// <exception cref="Exception">Thrown when the Stripe event does not contain an subscription object.</exception> |
||||
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null subscription object.</exception> |
||||
Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null); |
||||
|
||||
/// <summary> |
||||
/// Ensures that the customer associated with the Stripe <see cref="Event"/> is in the correct region for this server. |
||||
/// We use the customer instead of the subscription given that all subscriptions have customers, but not all |
||||
/// customers have subscriptions. |
||||
/// </summary> |
||||
/// <param name="stripeEvent">The Stripe webhook event.</param> |
||||
/// <returns>True if the customer's region and the server's region match, otherwise false.</returns> |
||||
Task<bool> ValidateCloudRegion(Event stripeEvent); |
||||
} |
||||
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
using Stripe; |
||||
|
||||
namespace Bit.Billing.Services; |
||||
|
||||
public interface IStripeFacade |
||||
{ |
||||
Task<Charge> GetCharge( |
||||
string chargeId, |
||||
ChargeGetOptions chargeGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default); |
||||
|
||||
Task<Customer> GetCustomer( |
||||
string customerId, |
||||
CustomerGetOptions customerGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default); |
||||
|
||||
Task<Invoice> GetInvoice( |
||||
string invoiceId, |
||||
InvoiceGetOptions invoiceGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default); |
||||
|
||||
Task<PaymentMethod> GetPaymentMethod( |
||||
string paymentMethodId, |
||||
PaymentMethodGetOptions paymentMethodGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default); |
||||
|
||||
Task<Subscription> GetSubscription( |
||||
string subscriptionId, |
||||
SubscriptionGetOptions subscriptionGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default); |
||||
} |
||||
@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
|
||||
using Bit.Billing.Constants; |
||||
using Bit.Core.Settings; |
||||
using Stripe; |
||||
|
||||
namespace Bit.Billing.Services.Implementations; |
||||
|
||||
public class StripeEventService : IStripeEventService |
||||
{ |
||||
private readonly GlobalSettings _globalSettings; |
||||
private readonly IStripeFacade _stripeFacade; |
||||
|
||||
public StripeEventService( |
||||
GlobalSettings globalSettings, |
||||
IStripeFacade stripeFacade) |
||||
{ |
||||
_globalSettings = globalSettings; |
||||
_stripeFacade = stripeFacade; |
||||
} |
||||
|
||||
public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null) |
||||
{ |
||||
var eventCharge = Extract<Charge>(stripeEvent); |
||||
|
||||
if (!fresh) |
||||
{ |
||||
return eventCharge; |
||||
} |
||||
|
||||
var charge = await _stripeFacade.GetCharge(eventCharge.Id, new ChargeGetOptions { Expand = expand }); |
||||
|
||||
if (charge == null) |
||||
{ |
||||
throw new Exception( |
||||
$"Received null Charge from Stripe for ID '{eventCharge.Id}' while processing Event with ID '{stripeEvent.Id}'"); |
||||
} |
||||
|
||||
return charge; |
||||
} |
||||
|
||||
public async Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null) |
||||
{ |
||||
var eventCustomer = Extract<Customer>(stripeEvent); |
||||
|
||||
if (!fresh) |
||||
{ |
||||
return eventCustomer; |
||||
} |
||||
|
||||
var customer = await _stripeFacade.GetCustomer(eventCustomer.Id, new CustomerGetOptions { Expand = expand }); |
||||
|
||||
if (customer == null) |
||||
{ |
||||
throw new Exception( |
||||
$"Received null Customer from Stripe for ID '{eventCustomer.Id}' while processing Event with ID '{stripeEvent.Id}'"); |
||||
} |
||||
|
||||
return customer; |
||||
} |
||||
|
||||
public async Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null) |
||||
{ |
||||
var eventInvoice = Extract<Invoice>(stripeEvent); |
||||
|
||||
if (!fresh) |
||||
{ |
||||
return eventInvoice; |
||||
} |
||||
|
||||
var invoice = await _stripeFacade.GetInvoice(eventInvoice.Id, new InvoiceGetOptions { Expand = expand }); |
||||
|
||||
if (invoice == null) |
||||
{ |
||||
throw new Exception( |
||||
$"Received null Invoice from Stripe for ID '{eventInvoice.Id}' while processing Event with ID '{stripeEvent.Id}'"); |
||||
} |
||||
|
||||
return invoice; |
||||
} |
||||
|
||||
public async Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null) |
||||
{ |
||||
var eventPaymentMethod = Extract<PaymentMethod>(stripeEvent); |
||||
|
||||
if (!fresh) |
||||
{ |
||||
return eventPaymentMethod; |
||||
} |
||||
|
||||
var paymentMethod = await _stripeFacade.GetPaymentMethod(eventPaymentMethod.Id, new PaymentMethodGetOptions { Expand = expand }); |
||||
|
||||
if (paymentMethod == null) |
||||
{ |
||||
throw new Exception( |
||||
$"Received null Payment Method from Stripe for ID '{eventPaymentMethod.Id}' while processing Event with ID '{stripeEvent.Id}'"); |
||||
} |
||||
|
||||
return paymentMethod; |
||||
} |
||||
|
||||
public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null) |
||||
{ |
||||
var eventSubscription = Extract<Subscription>(stripeEvent); |
||||
|
||||
if (!fresh) |
||||
{ |
||||
return eventSubscription; |
||||
} |
||||
|
||||
var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand }); |
||||
|
||||
if (subscription == null) |
||||
{ |
||||
throw new Exception( |
||||
$"Received null Subscription from Stripe for ID '{eventSubscription.Id}' while processing Event with ID '{stripeEvent.Id}'"); |
||||
} |
||||
|
||||
return subscription; |
||||
} |
||||
|
||||
public async Task<bool> ValidateCloudRegion(Event stripeEvent) |
||||
{ |
||||
var serverRegion = _globalSettings.BaseServiceUri.CloudRegion; |
||||
|
||||
var customerExpansion = new List<string> { "customer" }; |
||||
|
||||
var customerMetadata = stripeEvent.Type switch |
||||
{ |
||||
HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated => |
||||
(await GetSubscription(stripeEvent, true, customerExpansion))?.Customer?.Metadata, |
||||
|
||||
HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded => |
||||
(await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata, |
||||
|
||||
HandledStripeWebhook.UpcomingInvoice => |
||||
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata, |
||||
|
||||
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated => |
||||
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata, |
||||
|
||||
HandledStripeWebhook.PaymentMethodAttached => |
||||
(await GetPaymentMethod(stripeEvent, true, customerExpansion))?.Customer?.Metadata, |
||||
|
||||
HandledStripeWebhook.CustomerUpdated => |
||||
(await GetCustomer(stripeEvent, true))?.Metadata, |
||||
|
||||
_ => null |
||||
}; |
||||
|
||||
if (customerMetadata == null) |
||||
{ |
||||
return false; |
||||
} |
||||
|
||||
var customerRegion = GetCustomerRegion(customerMetadata); |
||||
|
||||
return customerRegion == serverRegion; |
||||
} |
||||
|
||||
private static T Extract<T>(Event stripeEvent) |
||||
{ |
||||
if (stripeEvent.Data.Object is not T type) |
||||
{ |
||||
throw new Exception($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'"); |
||||
} |
||||
|
||||
return type; |
||||
} |
||||
|
||||
private static string GetCustomerRegion(IDictionary<string, string> customerMetadata) |
||||
{ |
||||
const string defaultRegion = "US"; |
||||
|
||||
if (customerMetadata is null) |
||||
{ |
||||
return null; |
||||
} |
||||
|
||||
if (customerMetadata.TryGetValue("region", out var value)) |
||||
{ |
||||
return value; |
||||
} |
||||
|
||||
var miscasedRegionKey = customerMetadata.Keys |
||||
.FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase)); |
||||
|
||||
if (miscasedRegionKey is null) |
||||
{ |
||||
return defaultRegion; |
||||
} |
||||
|
||||
_ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue); |
||||
|
||||
return !string.IsNullOrWhiteSpace(regionValue) |
||||
? regionValue |
||||
: defaultRegion; |
||||
} |
||||
} |
||||
@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
|
||||
using Stripe; |
||||
|
||||
namespace Bit.Billing.Services.Implementations; |
||||
|
||||
public class StripeFacade : IStripeFacade |
||||
{ |
||||
private readonly ChargeService _chargeService = new(); |
||||
private readonly CustomerService _customerService = new(); |
||||
private readonly InvoiceService _invoiceService = new(); |
||||
private readonly PaymentMethodService _paymentMethodService = new(); |
||||
private readonly SubscriptionService _subscriptionService = new(); |
||||
|
||||
public async Task<Charge> GetCharge( |
||||
string chargeId, |
||||
ChargeGetOptions chargeGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default) => |
||||
await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken); |
||||
|
||||
public async Task<Customer> GetCustomer( |
||||
string customerId, |
||||
CustomerGetOptions customerGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default) => |
||||
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken); |
||||
|
||||
public async Task<Invoice> GetInvoice( |
||||
string invoiceId, |
||||
InvoiceGetOptions invoiceGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default) => |
||||
await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken); |
||||
|
||||
public async Task<PaymentMethod> GetPaymentMethod( |
||||
string paymentMethodId, |
||||
PaymentMethodGetOptions paymentMethodGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default) => |
||||
await _paymentMethodService.GetAsync(paymentMethodId, paymentMethodGetOptions, requestOptions, cancellationToken); |
||||
|
||||
public async Task<Subscription> GetSubscription( |
||||
string subscriptionId, |
||||
SubscriptionGetOptions subscriptionGetOptions = null, |
||||
RequestOptions requestOptions = null, |
||||
CancellationToken cancellationToken = default) => |
||||
await _subscriptionService.GetAsync(subscriptionId, subscriptionGetOptions, requestOptions, cancellationToken); |
||||
} |
||||
@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
|
||||
{ |
||||
"id": "evt_3NvKgBIGBnsLynRr0pJJqudS", |
||||
"object": "event", |
||||
"api_version": "2022-08-01", |
||||
"created": 1695909300, |
||||
"data": { |
||||
"object": { |
||||
"id": "ch_3NvKgBIGBnsLynRr0ZyvP9AN", |
||||
"object": "charge", |
||||
"amount": 7200, |
||||
"amount_captured": 7200, |
||||
"amount_refunded": 0, |
||||
"application": null, |
||||
"application_fee": null, |
||||
"application_fee_amount": null, |
||||
"balance_transaction": "txn_3NvKgBIGBnsLynRr0KbYEz76", |
||||
"billing_details": { |
||||
"address": { |
||||
"city": null, |
||||
"country": null, |
||||
"line1": null, |
||||
"line2": null, |
||||
"postal_code": null, |
||||
"state": null |
||||
}, |
||||
"email": null, |
||||
"name": null, |
||||
"phone": null |
||||
}, |
||||
"calculated_statement_descriptor": "BITWARDEN", |
||||
"captured": true, |
||||
"created": 1695909299, |
||||
"currency": "usd", |
||||
"customer": "cus_OimAwOzQmThNXx", |
||||
"description": "Subscription update", |
||||
"destination": null, |
||||
"dispute": null, |
||||
"disputed": false, |
||||
"failure_balance_transaction": null, |
||||
"failure_code": null, |
||||
"failure_message": null, |
||||
"fraud_details": { |
||||
}, |
||||
"invoice": "in_1NvKgBIGBnsLynRrmRFHAcoV", |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"on_behalf_of": null, |
||||
"order": null, |
||||
"outcome": { |
||||
"network_status": "approved_by_network", |
||||
"reason": null, |
||||
"risk_level": "normal", |
||||
"risk_score": 37, |
||||
"seller_message": "Payment complete.", |
||||
"type": "authorized" |
||||
}, |
||||
"paid": true, |
||||
"payment_intent": "pi_3NvKgBIGBnsLynRr09Ny3Heu", |
||||
"payment_method": "pm_1NvKbpIGBnsLynRrcOwez4A1", |
||||
"payment_method_details": { |
||||
"card": { |
||||
"amount_authorized": 7200, |
||||
"brand": "visa", |
||||
"checks": { |
||||
"address_line1_check": null, |
||||
"address_postal_code_check": null, |
||||
"cvc_check": "pass" |
||||
}, |
||||
"country": "US", |
||||
"exp_month": 6, |
||||
"exp_year": 2033, |
||||
"extended_authorization": { |
||||
"status": "disabled" |
||||
}, |
||||
"fingerprint": "0VgUBpvqcUUnuSmK", |
||||
"funding": "credit", |
||||
"incremental_authorization": { |
||||
"status": "unavailable" |
||||
}, |
||||
"installments": null, |
||||
"last4": "4242", |
||||
"mandate": null, |
||||
"multicapture": { |
||||
"status": "unavailable" |
||||
}, |
||||
"network": "visa", |
||||
"network_token": { |
||||
"used": false |
||||
}, |
||||
"overcapture": { |
||||
"maximum_amount_capturable": 7200, |
||||
"status": "unavailable" |
||||
}, |
||||
"three_d_secure": null, |
||||
"wallet": null |
||||
}, |
||||
"type": "card" |
||||
}, |
||||
"receipt_email": "cturnbull@bitwarden.com", |
||||
"receipt_number": null, |
||||
"receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xOXNtSVhJR0Juc0x5blJyKLSL1qgGMgYTnk_JOUA6LBY_SDEZNtuae1guQ6Dlcuev1TUHwn712t-UNnZdIc383zS15bXv_1dby8e4?s=ap", |
||||
"refunded": false, |
||||
"refunds": { |
||||
"object": "list", |
||||
"data": [ |
||||
], |
||||
"has_more": false, |
||||
"total_count": 0, |
||||
"url": "/v1/charges/ch_3NvKgBIGBnsLynRr0ZyvP9AN/refunds" |
||||
}, |
||||
"review": null, |
||||
"shipping": null, |
||||
"source": null, |
||||
"source_transfer": null, |
||||
"statement_descriptor": null, |
||||
"statement_descriptor_suffix": null, |
||||
"status": "succeeded", |
||||
"transfer_data": null, |
||||
"transfer_group": null |
||||
} |
||||
}, |
||||
"livemode": false, |
||||
"pending_webhooks": 9, |
||||
"request": { |
||||
"id": "req_rig8N5Ca8EXYRy", |
||||
"idempotency_key": "db75068d-5d90-4c65-a410-4e2ed8347509" |
||||
}, |
||||
"type": "charge.succeeded" |
||||
} |
||||
@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
|
||||
{ |
||||
"id": "evt_1NvLMDIGBnsLynRr6oBxebrE", |
||||
"object": "event", |
||||
"api_version": "2022-08-01", |
||||
"created": 1695911902, |
||||
"data": { |
||||
"object": { |
||||
"id": "sub_1NvKoKIGBnsLynRrcLIAUWGf", |
||||
"object": "subscription", |
||||
"application": null, |
||||
"application_fee_percent": null, |
||||
"automatic_tax": { |
||||
"enabled": false |
||||
}, |
||||
"billing_cycle_anchor": 1695911900, |
||||
"billing_thresholds": null, |
||||
"cancel_at": null, |
||||
"cancel_at_period_end": false, |
||||
"canceled_at": null, |
||||
"cancellation_details": { |
||||
"comment": null, |
||||
"feedback": null, |
||||
"reason": null |
||||
}, |
||||
"collection_method": "charge_automatically", |
||||
"created": 1695909804, |
||||
"currency": "usd", |
||||
"current_period_end": 1727534300, |
||||
"current_period_start": 1695911900, |
||||
"customer": "cus_OimNNCC3RiI2HQ", |
||||
"days_until_due": null, |
||||
"default_payment_method": null, |
||||
"default_source": null, |
||||
"default_tax_rates": [ |
||||
], |
||||
"description": null, |
||||
"discount": null, |
||||
"ended_at": null, |
||||
"items": { |
||||
"object": "list", |
||||
"data": [ |
||||
{ |
||||
"id": "si_OimNgVtrESpqus", |
||||
"object": "subscription_item", |
||||
"billing_thresholds": null, |
||||
"created": 1695909805, |
||||
"metadata": { |
||||
}, |
||||
"plan": { |
||||
"id": "enterprise-org-seat-annually", |
||||
"object": "plan", |
||||
"active": true, |
||||
"aggregate_usage": null, |
||||
"amount": 3600, |
||||
"amount_decimal": "3600", |
||||
"billing_scheme": "per_unit", |
||||
"created": 1494268677, |
||||
"currency": "usd", |
||||
"interval": "year", |
||||
"interval_count": 1, |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"nickname": "2019 Enterprise Seat (Annually)", |
||||
"product": "prod_BUtogGemxnTi9z", |
||||
"tiers_mode": null, |
||||
"transform_usage": null, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"price": { |
||||
"id": "enterprise-org-seat-annually", |
||||
"object": "price", |
||||
"active": true, |
||||
"billing_scheme": "per_unit", |
||||
"created": 1494268677, |
||||
"currency": "usd", |
||||
"custom_unit_amount": null, |
||||
"livemode": false, |
||||
"lookup_key": null, |
||||
"metadata": { |
||||
}, |
||||
"nickname": "2019 Enterprise Seat (Annually)", |
||||
"product": "prod_BUtogGemxnTi9z", |
||||
"recurring": { |
||||
"aggregate_usage": null, |
||||
"interval": "year", |
||||
"interval_count": 1, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"tax_behavior": "unspecified", |
||||
"tiers_mode": null, |
||||
"transform_quantity": null, |
||||
"type": "recurring", |
||||
"unit_amount": 3600, |
||||
"unit_amount_decimal": "3600" |
||||
}, |
||||
"quantity": 1, |
||||
"subscription": "sub_1NvKoKIGBnsLynRrcLIAUWGf", |
||||
"tax_rates": [ |
||||
] |
||||
} |
||||
], |
||||
"has_more": false, |
||||
"total_count": 1, |
||||
"url": "/v1/subscription_items?subscription=sub_1NvKoKIGBnsLynRrcLIAUWGf" |
||||
}, |
||||
"latest_invoice": "in_1NvLM9IGBnsLynRrOysII07d", |
||||
"livemode": false, |
||||
"metadata": { |
||||
"organizationId": "84a569ea-4643-474a-83a9-b08b00e7a20d" |
||||
}, |
||||
"next_pending_invoice_item_invoice": null, |
||||
"on_behalf_of": null, |
||||
"pause_collection": null, |
||||
"payment_settings": { |
||||
"payment_method_options": null, |
||||
"payment_method_types": null, |
||||
"save_default_payment_method": "off" |
||||
}, |
||||
"pending_invoice_item_interval": null, |
||||
"pending_setup_intent": null, |
||||
"pending_update": null, |
||||
"plan": { |
||||
"id": "enterprise-org-seat-annually", |
||||
"object": "plan", |
||||
"active": true, |
||||
"aggregate_usage": null, |
||||
"amount": 3600, |
||||
"amount_decimal": "3600", |
||||
"billing_scheme": "per_unit", |
||||
"created": 1494268677, |
||||
"currency": "usd", |
||||
"interval": "year", |
||||
"interval_count": 1, |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"nickname": "2019 Enterprise Seat (Annually)", |
||||
"product": "prod_BUtogGemxnTi9z", |
||||
"tiers_mode": null, |
||||
"transform_usage": null, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"quantity": 1, |
||||
"schedule": null, |
||||
"start_date": 1695909804, |
||||
"status": "active", |
||||
"test_clock": null, |
||||
"transfer_data": null, |
||||
"trial_end": 1695911899, |
||||
"trial_settings": { |
||||
"end_behavior": { |
||||
"missing_payment_method": "create_invoice" |
||||
} |
||||
}, |
||||
"trial_start": 1695909804 |
||||
}, |
||||
"previous_attributes": { |
||||
"billing_cycle_anchor": 1696514604, |
||||
"current_period_end": 1696514604, |
||||
"current_period_start": 1695909804, |
||||
"latest_invoice": "in_1NvKoKIGBnsLynRrSNRC6oYI", |
||||
"status": "trialing", |
||||
"trial_end": 1696514604 |
||||
} |
||||
}, |
||||
"livemode": false, |
||||
"pending_webhooks": 8, |
||||
"request": { |
||||
"id": "req_DMZPUU3BI66zAx", |
||||
"idempotency_key": "3fd8b4a5-6a20-46ab-9f45-b37b02a8017f" |
||||
}, |
||||
"type": "customer.subscription.updated" |
||||
} |
||||
@ -0,0 +1,311 @@
@@ -0,0 +1,311 @@
|
||||
{ |
||||
"id": "evt_1NvKjSIGBnsLynRrS3MTK4DZ", |
||||
"object": "event", |
||||
"account": "acct_19smIXIGBnsLynRr", |
||||
"api_version": "2022-08-01", |
||||
"created": 1695909502, |
||||
"data": { |
||||
"object": { |
||||
"id": "cus_Of54kUr3gV88lM", |
||||
"object": "customer", |
||||
"address": { |
||||
"city": null, |
||||
"country": "US", |
||||
"line1": "", |
||||
"line2": null, |
||||
"postal_code": "33701", |
||||
"state": null |
||||
}, |
||||
"balance": 0, |
||||
"created": 1695056798, |
||||
"currency": "usd", |
||||
"default_source": "src_1NtAfeIGBnsLynRrYDrceax7", |
||||
"delinquent": false, |
||||
"description": "Premium User", |
||||
"discount": null, |
||||
"email": "premium@bitwarden.com", |
||||
"invoice_prefix": "C506E8CE", |
||||
"invoice_settings": { |
||||
"custom_fields": [ |
||||
{ |
||||
"name": "Subscriber", |
||||
"value": "Premium User" |
||||
} |
||||
], |
||||
"default_payment_method": "pm_1Nrku9IGBnsLynRrcsQ3hy6C", |
||||
"footer": null, |
||||
"rendering_options": null |
||||
}, |
||||
"livemode": false, |
||||
"metadata": { |
||||
"region": "US" |
||||
}, |
||||
"name": null, |
||||
"next_invoice_sequence": 2, |
||||
"phone": null, |
||||
"preferred_locales": [ |
||||
], |
||||
"shipping": null, |
||||
"tax_exempt": "none", |
||||
"test_clock": null, |
||||
"account_balance": 0, |
||||
"cards": { |
||||
"object": "list", |
||||
"data": [ |
||||
], |
||||
"has_more": false, |
||||
"total_count": 0, |
||||
"url": "/v1/customers/cus_Of54kUr3gV88lM/cards" |
||||
}, |
||||
"default_card": null, |
||||
"default_currency": "usd", |
||||
"sources": { |
||||
"object": "list", |
||||
"data": [ |
||||
{ |
||||
"id": "src_1NtAfeIGBnsLynRrYDrceax7", |
||||
"object": "source", |
||||
"ach_credit_transfer": { |
||||
"account_number": "test_b2d1c6415f6f", |
||||
"routing_number": "110000000", |
||||
"fingerprint": "ePO4hBQanSft3gvU", |
||||
"swift_code": "TSTEZ122", |
||||
"bank_name": "TEST BANK", |
||||
"refund_routing_number": null, |
||||
"refund_account_holder_type": null, |
||||
"refund_account_holder_name": null |
||||
}, |
||||
"amount": null, |
||||
"client_secret": "src_client_secret_bUAP2uDRw6Pwj0xYk32LmJ3K", |
||||
"created": 1695394170, |
||||
"currency": "usd", |
||||
"customer": "cus_Of54kUr3gV88lM", |
||||
"flow": "receiver", |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"owner": { |
||||
"address": null, |
||||
"email": "amount_0@stripe.com", |
||||
"name": null, |
||||
"phone": null, |
||||
"verified_address": null, |
||||
"verified_email": null, |
||||
"verified_name": null, |
||||
"verified_phone": null |
||||
}, |
||||
"receiver": { |
||||
"address": "110000000-test_b2d1c6415f6f", |
||||
"amount_charged": 0, |
||||
"amount_received": 0, |
||||
"amount_returned": 0, |
||||
"refund_attributes_method": "email", |
||||
"refund_attributes_status": "missing" |
||||
}, |
||||
"statement_descriptor": null, |
||||
"status": "pending", |
||||
"type": "ach_credit_transfer", |
||||
"usage": "reusable" |
||||
} |
||||
], |
||||
"has_more": false, |
||||
"total_count": 1, |
||||
"url": "/v1/customers/cus_Of54kUr3gV88lM/sources" |
||||
}, |
||||
"subscriptions": { |
||||
"object": "list", |
||||
"data": [ |
||||
{ |
||||
"id": "sub_1NrkuBIGBnsLynRrzjFGIjEw", |
||||
"object": "subscription", |
||||
"application": null, |
||||
"application_fee_percent": null, |
||||
"automatic_tax": { |
||||
"enabled": false |
||||
}, |
||||
"billing": "charge_automatically", |
||||
"billing_cycle_anchor": 1695056799, |
||||
"billing_thresholds": null, |
||||
"cancel_at": null, |
||||
"cancel_at_period_end": false, |
||||
"canceled_at": null, |
||||
"cancellation_details": { |
||||
"comment": null, |
||||
"feedback": null, |
||||
"reason": null |
||||
}, |
||||
"collection_method": "charge_automatically", |
||||
"created": 1695056799, |
||||
"currency": "usd", |
||||
"current_period_end": 1726679199, |
||||
"current_period_start": 1695056799, |
||||
"customer": "cus_Of54kUr3gV88lM", |
||||
"days_until_due": null, |
||||
"default_payment_method": null, |
||||
"default_source": null, |
||||
"default_tax_rates": [ |
||||
], |
||||
"description": null, |
||||
"discount": null, |
||||
"ended_at": null, |
||||
"invoice_customer_balance_settings": { |
||||
"consume_applied_balance_on_void": true |
||||
}, |
||||
"items": { |
||||
"object": "list", |
||||
"data": [ |
||||
{ |
||||
"id": "si_Of54i3aK9I5Wro", |
||||
"object": "subscription_item", |
||||
"billing_thresholds": null, |
||||
"created": 1695056800, |
||||
"metadata": { |
||||
}, |
||||
"plan": { |
||||
"id": "premium-annually", |
||||
"object": "plan", |
||||
"active": true, |
||||
"aggregate_usage": null, |
||||
"amount": 1000, |
||||
"amount_decimal": "1000", |
||||
"billing_scheme": "per_unit", |
||||
"created": 1499289328, |
||||
"currency": "usd", |
||||
"interval": "year", |
||||
"interval_count": 1, |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"name": "Premium (Annually)", |
||||
"nickname": "Premium (Annually)", |
||||
"product": "prod_BUqgYr48VzDuCg", |
||||
"statement_description": null, |
||||
"statement_descriptor": null, |
||||
"tiers": null, |
||||
"tiers_mode": null, |
||||
"transform_usage": null, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"price": { |
||||
"id": "premium-annually", |
||||
"object": "price", |
||||
"active": true, |
||||
"billing_scheme": "per_unit", |
||||
"created": 1499289328, |
||||
"currency": "usd", |
||||
"custom_unit_amount": null, |
||||
"livemode": false, |
||||
"lookup_key": null, |
||||
"metadata": { |
||||
}, |
||||
"nickname": "Premium (Annually)", |
||||
"product": "prod_BUqgYr48VzDuCg", |
||||
"recurring": { |
||||
"aggregate_usage": null, |
||||
"interval": "year", |
||||
"interval_count": 1, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"tax_behavior": "unspecified", |
||||
"tiers_mode": null, |
||||
"transform_quantity": null, |
||||
"type": "recurring", |
||||
"unit_amount": 1000, |
||||
"unit_amount_decimal": "1000" |
||||
}, |
||||
"quantity": 1, |
||||
"subscription": "sub_1NrkuBIGBnsLynRrzjFGIjEw", |
||||
"tax_rates": [ |
||||
] |
||||
} |
||||
], |
||||
"has_more": false, |
||||
"total_count": 1, |
||||
"url": "/v1/subscription_items?subscription=sub_1NrkuBIGBnsLynRrzjFGIjEw" |
||||
}, |
||||
"latest_invoice": "in_1NrkuBIGBnsLynRr40gyJTVU", |
||||
"livemode": false, |
||||
"metadata": { |
||||
"userId": "91f40b6d-ac3b-4348-804b-b0810119ac6a" |
||||
}, |
||||
"next_pending_invoice_item_invoice": null, |
||||
"on_behalf_of": null, |
||||
"pause_collection": null, |
||||
"payment_settings": { |
||||
"payment_method_options": null, |
||||
"payment_method_types": null, |
||||
"save_default_payment_method": "off" |
||||
}, |
||||
"pending_invoice_item_interval": null, |
||||
"pending_setup_intent": null, |
||||
"pending_update": null, |
||||
"plan": { |
||||
"id": "premium-annually", |
||||
"object": "plan", |
||||
"active": true, |
||||
"aggregate_usage": null, |
||||
"amount": 1000, |
||||
"amount_decimal": "1000", |
||||
"billing_scheme": "per_unit", |
||||
"created": 1499289328, |
||||
"currency": "usd", |
||||
"interval": "year", |
||||
"interval_count": 1, |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"name": "Premium (Annually)", |
||||
"nickname": "Premium (Annually)", |
||||
"product": "prod_BUqgYr48VzDuCg", |
||||
"statement_description": null, |
||||
"statement_descriptor": null, |
||||
"tiers": null, |
||||
"tiers_mode": null, |
||||
"transform_usage": null, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"quantity": 1, |
||||
"schedule": null, |
||||
"start": 1695056799, |
||||
"start_date": 1695056799, |
||||
"status": "active", |
||||
"tax_percent": null, |
||||
"test_clock": null, |
||||
"transfer_data": null, |
||||
"trial_end": null, |
||||
"trial_settings": { |
||||
"end_behavior": { |
||||
"missing_payment_method": "create_invoice" |
||||
} |
||||
}, |
||||
"trial_start": null |
||||
} |
||||
], |
||||
"has_more": false, |
||||
"total_count": 1, |
||||
"url": "/v1/customers/cus_Of54kUr3gV88lM/subscriptions" |
||||
}, |
||||
"tax_ids": { |
||||
"object": "list", |
||||
"data": [ |
||||
], |
||||
"has_more": false, |
||||
"total_count": 0, |
||||
"url": "/v1/customers/cus_Of54kUr3gV88lM/tax_ids" |
||||
}, |
||||
"tax_info": null, |
||||
"tax_info_verification": null |
||||
}, |
||||
"previous_attributes": { |
||||
"email": "premium-new@bitwarden.com" |
||||
} |
||||
}, |
||||
"livemode": false, |
||||
"pending_webhooks": 5, |
||||
"request": "req_2RtGdXCfiicFLx", |
||||
"type": "customer.updated", |
||||
"user_id": "acct_19smIXIGBnsLynRr" |
||||
} |
||||
@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
|
||||
{ |
||||
"id": "evt_1NvKzfIGBnsLynRr0SkwrlkE", |
||||
"object": "event", |
||||
"api_version": "2022-08-01", |
||||
"created": 1695910506, |
||||
"data": { |
||||
"object": { |
||||
"id": "in_1NvKzdIGBnsLynRr8fE8cpbg", |
||||
"object": "invoice", |
||||
"account_country": "US", |
||||
"account_name": "Bitwarden Inc.", |
||||
"account_tax_ids": null, |
||||
"amount_due": 0, |
||||
"amount_paid": 0, |
||||
"amount_remaining": 0, |
||||
"amount_shipping": 0, |
||||
"application": null, |
||||
"application_fee_amount": null, |
||||
"attempt_count": 0, |
||||
"attempted": true, |
||||
"auto_advance": false, |
||||
"automatic_tax": { |
||||
"enabled": false, |
||||
"status": null |
||||
}, |
||||
"billing_reason": "subscription_create", |
||||
"charge": null, |
||||
"collection_method": "charge_automatically", |
||||
"created": 1695910505, |
||||
"currency": "usd", |
||||
"custom_fields": [ |
||||
{ |
||||
"name": "Organization", |
||||
"value": "teams 2023 monthly - 2" |
||||
} |
||||
], |
||||
"customer": "cus_OimYrxnMTMMK1E", |
||||
"customer_address": { |
||||
"city": null, |
||||
"country": "US", |
||||
"line1": "", |
||||
"line2": null, |
||||
"postal_code": "12345", |
||||
"state": null |
||||
}, |
||||
"customer_email": "cturnbull@bitwarden.com", |
||||
"customer_name": null, |
||||
"customer_phone": null, |
||||
"customer_shipping": null, |
||||
"customer_tax_exempt": "none", |
||||
"customer_tax_ids": [ |
||||
], |
||||
"default_payment_method": null, |
||||
"default_source": null, |
||||
"default_tax_rates": [ |
||||
], |
||||
"description": null, |
||||
"discount": null, |
||||
"discounts": [ |
||||
], |
||||
"due_date": null, |
||||
"effective_at": 1695910505, |
||||
"ending_balance": 0, |
||||
"footer": null, |
||||
"from_invoice": null, |
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2?s=ap", |
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2/pdf?s=ap", |
||||
"last_finalization_error": null, |
||||
"latest_revision": null, |
||||
"lines": { |
||||
"object": "list", |
||||
"data": [ |
||||
{ |
||||
"id": "il_1NvKzdIGBnsLynRr2pS4ZA8e", |
||||
"object": "line_item", |
||||
"amount": 0, |
||||
"amount_excluding_tax": 0, |
||||
"currency": "usd", |
||||
"description": "Trial period for Teams Organization Seat", |
||||
"discount_amounts": [ |
||||
], |
||||
"discountable": true, |
||||
"discounts": [ |
||||
], |
||||
"livemode": false, |
||||
"metadata": { |
||||
"organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a" |
||||
}, |
||||
"period": { |
||||
"end": 1696515305, |
||||
"start": 1695910505 |
||||
}, |
||||
"plan": { |
||||
"id": "2020-teams-org-seat-monthly", |
||||
"object": "plan", |
||||
"active": true, |
||||
"aggregate_usage": null, |
||||
"amount": 400, |
||||
"amount_decimal": "400", |
||||
"billing_scheme": "per_unit", |
||||
"created": 1595263113, |
||||
"currency": "usd", |
||||
"interval": "month", |
||||
"interval_count": 1, |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"nickname": "Teams Organization Seat (Monthly) 2023", |
||||
"product": "prod_HgOooYXDr2DDAA", |
||||
"tiers_mode": null, |
||||
"transform_usage": null, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"price": { |
||||
"id": "2020-teams-org-seat-monthly", |
||||
"object": "price", |
||||
"active": true, |
||||
"billing_scheme": "per_unit", |
||||
"created": 1595263113, |
||||
"currency": "usd", |
||||
"custom_unit_amount": null, |
||||
"livemode": false, |
||||
"lookup_key": null, |
||||
"metadata": { |
||||
}, |
||||
"nickname": "Teams Organization Seat (Monthly) 2023", |
||||
"product": "prod_HgOooYXDr2DDAA", |
||||
"recurring": { |
||||
"aggregate_usage": null, |
||||
"interval": "month", |
||||
"interval_count": 1, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"tax_behavior": "unspecified", |
||||
"tiers_mode": null, |
||||
"transform_quantity": null, |
||||
"type": "recurring", |
||||
"unit_amount": 400, |
||||
"unit_amount_decimal": "400" |
||||
}, |
||||
"proration": false, |
||||
"proration_details": { |
||||
"credited_items": null |
||||
}, |
||||
"quantity": 1, |
||||
"subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc", |
||||
"subscription_item": "si_OimYNSbvuqdtTr", |
||||
"tax_amounts": [ |
||||
], |
||||
"tax_rates": [ |
||||
], |
||||
"type": "subscription", |
||||
"unit_amount_excluding_tax": "0" |
||||
} |
||||
], |
||||
"has_more": false, |
||||
"total_count": 1, |
||||
"url": "/v1/invoices/in_1NvKzdIGBnsLynRr8fE8cpbg/lines" |
||||
}, |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"next_payment_attempt": null, |
||||
"number": "3E96D078-0001", |
||||
"on_behalf_of": null, |
||||
"paid": true, |
||||
"paid_out_of_band": false, |
||||
"payment_intent": null, |
||||
"payment_settings": { |
||||
"default_mandate": null, |
||||
"payment_method_options": null, |
||||
"payment_method_types": null |
||||
}, |
||||
"period_end": 1695910505, |
||||
"period_start": 1695910505, |
||||
"post_payment_credit_notes_amount": 0, |
||||
"pre_payment_credit_notes_amount": 0, |
||||
"quote": null, |
||||
"receipt_number": null, |
||||
"rendering": null, |
||||
"rendering_options": null, |
||||
"shipping_cost": null, |
||||
"shipping_details": null, |
||||
"starting_balance": 0, |
||||
"statement_descriptor": null, |
||||
"status": "paid", |
||||
"status_transitions": { |
||||
"finalized_at": 1695910505, |
||||
"marked_uncollectible_at": null, |
||||
"paid_at": 1695910505, |
||||
"voided_at": null |
||||
}, |
||||
"subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc", |
||||
"subscription_details": { |
||||
"metadata": { |
||||
"organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a" |
||||
} |
||||
}, |
||||
"subtotal": 0, |
||||
"subtotal_excluding_tax": 0, |
||||
"tax": null, |
||||
"test_clock": null, |
||||
"total": 0, |
||||
"total_discount_amounts": [ |
||||
], |
||||
"total_excluding_tax": 0, |
||||
"total_tax_amounts": [ |
||||
], |
||||
"transfer_data": null, |
||||
"webhooks_delivered_at": null |
||||
} |
||||
}, |
||||
"livemode": false, |
||||
"pending_webhooks": 8, |
||||
"request": { |
||||
"id": "req_roIwONfgyfZdr4", |
||||
"idempotency_key": "dd2a171b-b9c7-4d2d-89d5-1ceae3c0595d" |
||||
}, |
||||
"type": "invoice.created" |
||||
} |
||||
@ -0,0 +1,225 @@
@@ -0,0 +1,225 @@
|
||||
{ |
||||
"id": "evt_1Nv0w8IGBnsLynRrZoDVI44u", |
||||
"object": "event", |
||||
"api_version": "2022-08-01", |
||||
"created": 1695833408, |
||||
"data": { |
||||
"object": { |
||||
"object": "invoice", |
||||
"account_country": "US", |
||||
"account_name": "Bitwarden Inc.", |
||||
"account_tax_ids": null, |
||||
"amount_due": 0, |
||||
"amount_paid": 0, |
||||
"amount_remaining": 0, |
||||
"amount_shipping": 0, |
||||
"application": null, |
||||
"application_fee_amount": null, |
||||
"attempt_count": 0, |
||||
"attempted": false, |
||||
"automatic_tax": { |
||||
"enabled": true, |
||||
"status": "complete" |
||||
}, |
||||
"billing_reason": "upcoming", |
||||
"charge": null, |
||||
"collection_method": "charge_automatically", |
||||
"created": 1697128681, |
||||
"currency": "usd", |
||||
"custom_fields": null, |
||||
"customer": "cus_M8DV9wiyNa2JxQ", |
||||
"customer_address": { |
||||
"city": null, |
||||
"country": "US", |
||||
"line1": "", |
||||
"line2": null, |
||||
"postal_code": "90019", |
||||
"state": null |
||||
}, |
||||
"customer_email": "vphan@bitwarden.com", |
||||
"customer_name": null, |
||||
"customer_phone": null, |
||||
"customer_shipping": null, |
||||
"customer_tax_exempt": "none", |
||||
"customer_tax_ids": [ |
||||
], |
||||
"default_payment_method": null, |
||||
"default_source": null, |
||||
"default_tax_rates": [ |
||||
], |
||||
"description": null, |
||||
"discount": null, |
||||
"discounts": [ |
||||
], |
||||
"due_date": null, |
||||
"effective_at": null, |
||||
"ending_balance": -6779, |
||||
"footer": null, |
||||
"from_invoice": null, |
||||
"last_finalization_error": null, |
||||
"latest_revision": null, |
||||
"lines": { |
||||
"object": "list", |
||||
"data": [ |
||||
{ |
||||
"id": "il_tmp_12b5e8IGBnsLynRr1996ac3a", |
||||
"object": "line_item", |
||||
"amount": 2000, |
||||
"amount_excluding_tax": 2000, |
||||
"currency": "usd", |
||||
"description": "5 × 2019 Enterprise Seat (Monthly) (at $4.00 / month)", |
||||
"discount_amounts": [ |
||||
], |
||||
"discountable": true, |
||||
"discounts": [ |
||||
], |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"period": { |
||||
"end": 1699807081, |
||||
"start": 1697128681 |
||||
}, |
||||
"plan": { |
||||
"id": "enterprise-org-seat-monthly", |
||||
"object": "plan", |
||||
"active": true, |
||||
"aggregate_usage": null, |
||||
"amount": 400, |
||||
"amount_decimal": "400", |
||||
"billing_scheme": "per_unit", |
||||
"created": 1494268635, |
||||
"currency": "usd", |
||||
"interval": "month", |
||||
"interval_count": 1, |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"nickname": "2019 Enterprise Seat (Monthly)", |
||||
"product": "prod_BVButYytPSlgs6", |
||||
"tiers_mode": null, |
||||
"transform_usage": null, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"price": { |
||||
"id": "enterprise-org-seat-monthly", |
||||
"object": "price", |
||||
"active": true, |
||||
"billing_scheme": "per_unit", |
||||
"created": 1494268635, |
||||
"currency": "usd", |
||||
"custom_unit_amount": null, |
||||
"livemode": false, |
||||
"lookup_key": null, |
||||
"metadata": { |
||||
}, |
||||
"nickname": "2019 Enterprise Seat (Monthly)", |
||||
"product": "prod_BVButYytPSlgs6", |
||||
"recurring": { |
||||
"aggregate_usage": null, |
||||
"interval": "month", |
||||
"interval_count": 1, |
||||
"trial_period_days": null, |
||||
"usage_type": "licensed" |
||||
}, |
||||
"tax_behavior": "unspecified", |
||||
"tiers_mode": null, |
||||
"transform_quantity": null, |
||||
"type": "recurring", |
||||
"unit_amount": 400, |
||||
"unit_amount_decimal": "400" |
||||
}, |
||||
"proration": false, |
||||
"proration_details": { |
||||
"credited_items": null |
||||
}, |
||||
"quantity": 5, |
||||
"subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v", |
||||
"subscription_item": "si_ODOmLnPDHBuMxX", |
||||
"tax_amounts": [ |
||||
{ |
||||
"amount": 0, |
||||
"inclusive": false, |
||||
"tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD", |
||||
"taxability_reason": "product_exempt", |
||||
"taxable_amount": 0 |
||||
} |
||||
], |
||||
"tax_rates": [ |
||||
], |
||||
"type": "subscription", |
||||
"unit_amount_excluding_tax": "400" |
||||
} |
||||
], |
||||
"has_more": false, |
||||
"total_count": 1, |
||||
"url": "/v1/invoices/upcoming/lines?customer=cus_M8DV9wiyNa2JxQ&subscription=sub_1NQxz4IGBnsLynRr1KbitG7v" |
||||
}, |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"next_payment_attempt": 1697132281, |
||||
"number": null, |
||||
"on_behalf_of": null, |
||||
"paid": false, |
||||
"paid_out_of_band": false, |
||||
"payment_intent": null, |
||||
"payment_settings": { |
||||
"default_mandate": null, |
||||
"payment_method_options": null, |
||||
"payment_method_types": null |
||||
}, |
||||
"period_end": 1697128681, |
||||
"period_start": 1694536681, |
||||
"post_payment_credit_notes_amount": 0, |
||||
"pre_payment_credit_notes_amount": 0, |
||||
"quote": null, |
||||
"receipt_number": null, |
||||
"rendering": null, |
||||
"rendering_options": null, |
||||
"shipping_cost": null, |
||||
"shipping_details": null, |
||||
"starting_balance": -8779, |
||||
"statement_descriptor": null, |
||||
"status": "draft", |
||||
"status_transitions": { |
||||
"finalized_at": null, |
||||
"marked_uncollectible_at": null, |
||||
"paid_at": null, |
||||
"voided_at": null |
||||
}, |
||||
"subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v", |
||||
"subscription_details": { |
||||
"metadata": { |
||||
} |
||||
}, |
||||
"subtotal": 2000, |
||||
"subtotal_excluding_tax": 2000, |
||||
"tax": 0, |
||||
"test_clock": null, |
||||
"total": 2000, |
||||
"total_discount_amounts": [ |
||||
], |
||||
"total_excluding_tax": 2000, |
||||
"total_tax_amounts": [ |
||||
{ |
||||
"amount": 0, |
||||
"inclusive": false, |
||||
"tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD", |
||||
"taxability_reason": "product_exempt", |
||||
"taxable_amount": 0 |
||||
} |
||||
], |
||||
"transfer_data": null, |
||||
"webhooks_delivered_at": null |
||||
} |
||||
}, |
||||
"livemode": false, |
||||
"pending_webhooks": 5, |
||||
"request": { |
||||
"id": null, |
||||
"idempotency_key": null |
||||
}, |
||||
"type": "invoice.upcoming" |
||||
} |
||||
@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
|
||||
{ |
||||
"id": "evt_1NvKzcIGBnsLynRrPJ3hybkd", |
||||
"object": "event", |
||||
"api_version": "2022-08-01", |
||||
"created": 1695910504, |
||||
"data": { |
||||
"object": { |
||||
"id": "pm_1NvKzbIGBnsLynRry6x7Buvc", |
||||
"object": "payment_method", |
||||
"billing_details": { |
||||
"address": { |
||||
"city": null, |
||||
"country": null, |
||||
"line1": null, |
||||
"line2": null, |
||||
"postal_code": null, |
||||
"state": null |
||||
}, |
||||
"email": null, |
||||
"name": null, |
||||
"phone": null |
||||
}, |
||||
"card": { |
||||
"brand": "visa", |
||||
"checks": { |
||||
"address_line1_check": null, |
||||
"address_postal_code_check": null, |
||||
"cvc_check": "pass" |
||||
}, |
||||
"country": "US", |
||||
"exp_month": 6, |
||||
"exp_year": 2033, |
||||
"fingerprint": "0VgUBpvqcUUnuSmK", |
||||
"funding": "credit", |
||||
"generated_from": null, |
||||
"last4": "4242", |
||||
"networks": { |
||||
"available": [ |
||||
"visa" |
||||
], |
||||
"preferred": null |
||||
}, |
||||
"three_d_secure_usage": { |
||||
"supported": true |
||||
}, |
||||
"wallet": null |
||||
}, |
||||
"created": 1695910503, |
||||
"customer": "cus_OimYrxnMTMMK1E", |
||||
"livemode": false, |
||||
"metadata": { |
||||
}, |
||||
"type": "card" |
||||
} |
||||
}, |
||||
"livemode": false, |
||||
"pending_webhooks": 7, |
||||
"request": { |
||||
"id": "req_2WslNSBD9wAV5v", |
||||
"idempotency_key": "db1a648a-3445-47b3-a403-9f3d1303a880" |
||||
}, |
||||
"type": "payment_method.attached" |
||||
} |
||||
@ -0,0 +1,691 @@
@@ -0,0 +1,691 @@
|
||||
using Bit.Billing.Services; |
||||
using Bit.Billing.Services.Implementations; |
||||
using Bit.Billing.Test.Utilities; |
||||
using Bit.Core.Settings; |
||||
using FluentAssertions; |
||||
using NSubstitute; |
||||
using Stripe; |
||||
using Xunit; |
||||
|
||||
namespace Bit.Billing.Test.Services; |
||||
|
||||
public class StripeEventServiceTests |
||||
{ |
||||
private readonly IStripeFacade _stripeFacade; |
||||
private readonly IStripeEventService _stripeEventService; |
||||
|
||||
public StripeEventServiceTests() |
||||
{ |
||||
var globalSettings = new GlobalSettings(); |
||||
var baseServiceUriSettings = new GlobalSettings.BaseServiceUriSettings(globalSettings) { CloudRegion = "US" }; |
||||
globalSettings.BaseServiceUri = baseServiceUriSettings; |
||||
|
||||
_stripeFacade = Substitute.For<IStripeFacade>(); |
||||
_stripeEventService = new StripeEventService(globalSettings, _stripeFacade); |
||||
} |
||||
|
||||
#region GetCharge |
||||
[Fact] |
||||
public async Task GetCharge_EventNotChargeRelated_ThrowsException() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); |
||||
|
||||
// Act |
||||
var function = async () => await _stripeEventService.GetCharge(stripeEvent); |
||||
|
||||
// Assert |
||||
await function |
||||
.Should() |
||||
.ThrowAsync<Exception>() |
||||
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'"); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( |
||||
Arg.Any<string>(), |
||||
Arg.Any<ChargeGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetCharge_NotFresh_ReturnsEventCharge() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); |
||||
|
||||
// Act |
||||
var charge = await _stripeEventService.GetCharge(stripeEvent); |
||||
|
||||
// Assert |
||||
charge.Should().BeEquivalentTo(stripeEvent.Data.Object as Charge); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( |
||||
Arg.Any<string>(), |
||||
Arg.Any<ChargeGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetCharge_Fresh_Expand_ReturnsAPICharge() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); |
||||
|
||||
var eventCharge = stripeEvent.Data.Object as Charge; |
||||
|
||||
var apiCharge = Copy(eventCharge); |
||||
|
||||
var expand = new List<string> { "customer" }; |
||||
|
||||
_stripeFacade.GetCharge( |
||||
apiCharge.Id, |
||||
Arg.Is<ChargeGetOptions>(options => options.Expand == expand)) |
||||
.Returns(apiCharge); |
||||
|
||||
// Act |
||||
var charge = await _stripeEventService.GetCharge(stripeEvent, true, expand); |
||||
|
||||
// Assert |
||||
charge.Should().Be(apiCharge); |
||||
charge.Should().NotBeSameAs(eventCharge); |
||||
|
||||
await _stripeFacade.Received().GetCharge( |
||||
apiCharge.Id, |
||||
Arg.Is<ChargeGetOptions>(options => options.Expand == expand), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
#endregion |
||||
|
||||
#region GetCustomer |
||||
[Fact] |
||||
public async Task GetCustomer_EventNotCustomerRelated_ThrowsException() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); |
||||
|
||||
// Act |
||||
var function = async () => await _stripeEventService.GetCustomer(stripeEvent); |
||||
|
||||
// Assert |
||||
await function |
||||
.Should() |
||||
.ThrowAsync<Exception>() |
||||
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'"); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( |
||||
Arg.Any<string>(), |
||||
Arg.Any<CustomerGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetCustomer_NotFresh_ReturnsEventCustomer() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); |
||||
|
||||
// Act |
||||
var customer = await _stripeEventService.GetCustomer(stripeEvent); |
||||
|
||||
// Assert |
||||
customer.Should().BeEquivalentTo(stripeEvent.Data.Object as Customer); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( |
||||
Arg.Any<string>(), |
||||
Arg.Any<CustomerGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetCustomer_Fresh_Expand_ReturnsAPICustomer() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); |
||||
|
||||
var eventCustomer = stripeEvent.Data.Object as Customer; |
||||
|
||||
var apiCustomer = Copy(eventCustomer); |
||||
|
||||
var expand = new List<string> { "subscriptions" }; |
||||
|
||||
_stripeFacade.GetCustomer( |
||||
apiCustomer.Id, |
||||
Arg.Is<CustomerGetOptions>(options => options.Expand == expand)) |
||||
.Returns(apiCustomer); |
||||
|
||||
// Act |
||||
var customer = await _stripeEventService.GetCustomer(stripeEvent, true, expand); |
||||
|
||||
// Assert |
||||
customer.Should().Be(apiCustomer); |
||||
customer.Should().NotBeSameAs(eventCustomer); |
||||
|
||||
await _stripeFacade.Received().GetCustomer( |
||||
apiCustomer.Id, |
||||
Arg.Is<CustomerGetOptions>(options => options.Expand == expand), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
#endregion |
||||
|
||||
#region GetInvoice |
||||
[Fact] |
||||
public async Task GetInvoice_EventNotInvoiceRelated_ThrowsException() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); |
||||
|
||||
// Act |
||||
var function = async () => await _stripeEventService.GetInvoice(stripeEvent); |
||||
|
||||
// Assert |
||||
await function |
||||
.Should() |
||||
.ThrowAsync<Exception>() |
||||
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'"); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( |
||||
Arg.Any<string>(), |
||||
Arg.Any<InvoiceGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetInvoice_NotFresh_ReturnsEventInvoice() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); |
||||
|
||||
// Act |
||||
var invoice = await _stripeEventService.GetInvoice(stripeEvent); |
||||
|
||||
// Assert |
||||
invoice.Should().BeEquivalentTo(stripeEvent.Data.Object as Invoice); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( |
||||
Arg.Any<string>(), |
||||
Arg.Any<InvoiceGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetInvoice_Fresh_Expand_ReturnsAPIInvoice() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); |
||||
|
||||
var eventInvoice = stripeEvent.Data.Object as Invoice; |
||||
|
||||
var apiInvoice = Copy(eventInvoice); |
||||
|
||||
var expand = new List<string> { "customer" }; |
||||
|
||||
_stripeFacade.GetInvoice( |
||||
apiInvoice.Id, |
||||
Arg.Is<InvoiceGetOptions>(options => options.Expand == expand)) |
||||
.Returns(apiInvoice); |
||||
|
||||
// Act |
||||
var invoice = await _stripeEventService.GetInvoice(stripeEvent, true, expand); |
||||
|
||||
// Assert |
||||
invoice.Should().Be(apiInvoice); |
||||
invoice.Should().NotBeSameAs(eventInvoice); |
||||
|
||||
await _stripeFacade.Received().GetInvoice( |
||||
apiInvoice.Id, |
||||
Arg.Is<InvoiceGetOptions>(options => options.Expand == expand), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
#endregion |
||||
|
||||
#region GetPaymentMethod |
||||
[Fact] |
||||
public async Task GetPaymentMethod_EventNotPaymentMethodRelated_ThrowsException() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); |
||||
|
||||
// Act |
||||
var function = async () => await _stripeEventService.GetPaymentMethod(stripeEvent); |
||||
|
||||
// Assert |
||||
await function |
||||
.Should() |
||||
.ThrowAsync<Exception>() |
||||
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'"); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( |
||||
Arg.Any<string>(), |
||||
Arg.Any<PaymentMethodGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetPaymentMethod_NotFresh_ReturnsEventPaymentMethod() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); |
||||
|
||||
// Act |
||||
var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent); |
||||
|
||||
// Assert |
||||
paymentMethod.Should().BeEquivalentTo(stripeEvent.Data.Object as PaymentMethod); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( |
||||
Arg.Any<string>(), |
||||
Arg.Any<PaymentMethodGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetPaymentMethod_Fresh_Expand_ReturnsAPIPaymentMethod() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); |
||||
|
||||
var eventPaymentMethod = stripeEvent.Data.Object as PaymentMethod; |
||||
|
||||
var apiPaymentMethod = Copy(eventPaymentMethod); |
||||
|
||||
var expand = new List<string> { "customer" }; |
||||
|
||||
_stripeFacade.GetPaymentMethod( |
||||
apiPaymentMethod.Id, |
||||
Arg.Is<PaymentMethodGetOptions>(options => options.Expand == expand)) |
||||
.Returns(apiPaymentMethod); |
||||
|
||||
// Act |
||||
var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent, true, expand); |
||||
|
||||
// Assert |
||||
paymentMethod.Should().Be(apiPaymentMethod); |
||||
paymentMethod.Should().NotBeSameAs(eventPaymentMethod); |
||||
|
||||
await _stripeFacade.Received().GetPaymentMethod( |
||||
apiPaymentMethod.Id, |
||||
Arg.Is<PaymentMethodGetOptions>(options => options.Expand == expand), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
#endregion |
||||
|
||||
#region GetSubscription |
||||
[Fact] |
||||
public async Task GetSubscription_EventNotSubscriptionRelated_ThrowsException() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); |
||||
|
||||
// Act |
||||
var function = async () => await _stripeEventService.GetSubscription(stripeEvent); |
||||
|
||||
// Assert |
||||
await function |
||||
.Should() |
||||
.ThrowAsync<Exception>() |
||||
.WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'"); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( |
||||
Arg.Any<string>(), |
||||
Arg.Any<SubscriptionGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetSubscription_NotFresh_ReturnsEventSubscription() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); |
||||
|
||||
// Act |
||||
var subscription = await _stripeEventService.GetSubscription(stripeEvent); |
||||
|
||||
// Assert |
||||
subscription.Should().BeEquivalentTo(stripeEvent.Data.Object as Subscription); |
||||
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( |
||||
Arg.Any<string>(), |
||||
Arg.Any<SubscriptionGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task GetSubscription_Fresh_Expand_ReturnsAPISubscription() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); |
||||
|
||||
var eventSubscription = stripeEvent.Data.Object as Subscription; |
||||
|
||||
var apiSubscription = Copy(eventSubscription); |
||||
|
||||
var expand = new List<string> { "customer" }; |
||||
|
||||
_stripeFacade.GetSubscription( |
||||
apiSubscription.Id, |
||||
Arg.Is<SubscriptionGetOptions>(options => options.Expand == expand)) |
||||
.Returns(apiSubscription); |
||||
|
||||
// Act |
||||
var subscription = await _stripeEventService.GetSubscription(stripeEvent, true, expand); |
||||
|
||||
// Assert |
||||
subscription.Should().Be(apiSubscription); |
||||
subscription.Should().NotBeSameAs(eventSubscription); |
||||
|
||||
await _stripeFacade.Received().GetSubscription( |
||||
apiSubscription.Id, |
||||
Arg.Is<SubscriptionGetOptions>(options => options.Expand == expand), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
#endregion |
||||
|
||||
#region ValidateCloudRegion |
||||
[Fact] |
||||
public async Task ValidateCloudRegion_SubscriptionUpdated_Success() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); |
||||
|
||||
var subscription = Copy(stripeEvent.Data.Object as Subscription); |
||||
|
||||
var customer = await GetCustomerAsync(); |
||||
|
||||
subscription.Customer = customer; |
||||
|
||||
_stripeFacade.GetSubscription( |
||||
subscription.Id, |
||||
Arg.Any<SubscriptionGetOptions>()) |
||||
.Returns(subscription); |
||||
|
||||
// Act |
||||
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); |
||||
|
||||
// Assert |
||||
cloudRegionValid.Should().BeTrue(); |
||||
|
||||
await _stripeFacade.Received(1).GetSubscription( |
||||
subscription.Id, |
||||
Arg.Any<SubscriptionGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task ValidateCloudRegion_ChargeSucceeded_Success() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); |
||||
|
||||
var charge = Copy(stripeEvent.Data.Object as Charge); |
||||
|
||||
var customer = await GetCustomerAsync(); |
||||
|
||||
charge.Customer = customer; |
||||
|
||||
_stripeFacade.GetCharge( |
||||
charge.Id, |
||||
Arg.Any<ChargeGetOptions>()) |
||||
.Returns(charge); |
||||
|
||||
// Act |
||||
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); |
||||
|
||||
// Assert |
||||
cloudRegionValid.Should().BeTrue(); |
||||
|
||||
await _stripeFacade.Received(1).GetCharge( |
||||
charge.Id, |
||||
Arg.Any<ChargeGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task ValidateCloudRegion_UpcomingInvoice_Success() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceUpcoming); |
||||
|
||||
var invoice = Copy(stripeEvent.Data.Object as Invoice); |
||||
|
||||
var customer = await GetCustomerAsync(); |
||||
|
||||
invoice.Customer = customer; |
||||
|
||||
_stripeFacade.GetInvoice( |
||||
invoice.Id, |
||||
Arg.Any<InvoiceGetOptions>()) |
||||
.Returns(invoice); |
||||
|
||||
// Act |
||||
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); |
||||
|
||||
// Assert |
||||
cloudRegionValid.Should().BeTrue(); |
||||
|
||||
await _stripeFacade.Received(1).GetInvoice( |
||||
invoice.Id, |
||||
Arg.Any<InvoiceGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task ValidateCloudRegion_InvoiceCreated_Success() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); |
||||
|
||||
var invoice = Copy(stripeEvent.Data.Object as Invoice); |
||||
|
||||
var customer = await GetCustomerAsync(); |
||||
|
||||
invoice.Customer = customer; |
||||
|
||||
_stripeFacade.GetInvoice( |
||||
invoice.Id, |
||||
Arg.Any<InvoiceGetOptions>()) |
||||
.Returns(invoice); |
||||
|
||||
// Act |
||||
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); |
||||
|
||||
// Assert |
||||
cloudRegionValid.Should().BeTrue(); |
||||
|
||||
await _stripeFacade.Received(1).GetInvoice( |
||||
invoice.Id, |
||||
Arg.Any<InvoiceGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task ValidateCloudRegion_PaymentMethodAttached_Success() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); |
||||
|
||||
var paymentMethod = Copy(stripeEvent.Data.Object as PaymentMethod); |
||||
|
||||
var customer = await GetCustomerAsync(); |
||||
|
||||
paymentMethod.Customer = customer; |
||||
|
||||
_stripeFacade.GetPaymentMethod( |
||||
paymentMethod.Id, |
||||
Arg.Any<PaymentMethodGetOptions>()) |
||||
.Returns(paymentMethod); |
||||
|
||||
// Act |
||||
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); |
||||
|
||||
// Assert |
||||
cloudRegionValid.Should().BeTrue(); |
||||
|
||||
await _stripeFacade.Received(1).GetPaymentMethod( |
||||
paymentMethod.Id, |
||||
Arg.Any<PaymentMethodGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task ValidateCloudRegion_CustomerUpdated_Success() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); |
||||
|
||||
var customer = Copy(stripeEvent.Data.Object as Customer); |
||||
|
||||
_stripeFacade.GetCustomer( |
||||
customer.Id, |
||||
Arg.Any<CustomerGetOptions>()) |
||||
.Returns(customer); |
||||
|
||||
// Act |
||||
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); |
||||
|
||||
// Assert |
||||
cloudRegionValid.Should().BeTrue(); |
||||
|
||||
await _stripeFacade.Received(1).GetCustomer( |
||||
customer.Id, |
||||
Arg.Any<CustomerGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task ValidateCloudRegion_MetadataNull_ReturnsFalse() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); |
||||
|
||||
var subscription = Copy(stripeEvent.Data.Object as Subscription); |
||||
|
||||
var customer = await GetCustomerAsync(); |
||||
customer.Metadata = null; |
||||
|
||||
subscription.Customer = customer; |
||||
|
||||
_stripeFacade.GetSubscription( |
||||
subscription.Id, |
||||
Arg.Any<SubscriptionGetOptions>()) |
||||
.Returns(subscription); |
||||
|
||||
// Act |
||||
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); |
||||
|
||||
// Assert |
||||
cloudRegionValid.Should().BeFalse(); |
||||
|
||||
await _stripeFacade.Received(1).GetSubscription( |
||||
subscription.Id, |
||||
Arg.Any<SubscriptionGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task ValidateCloudRegion_MetadataNoRegion_DefaultUS_ReturnsTrue() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); |
||||
|
||||
var subscription = Copy(stripeEvent.Data.Object as Subscription); |
||||
|
||||
var customer = await GetCustomerAsync(); |
||||
customer.Metadata = new Dictionary<string, string>(); |
||||
|
||||
subscription.Customer = customer; |
||||
|
||||
_stripeFacade.GetSubscription( |
||||
subscription.Id, |
||||
Arg.Any<SubscriptionGetOptions>()) |
||||
.Returns(subscription); |
||||
|
||||
// Act |
||||
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); |
||||
|
||||
// Assert |
||||
cloudRegionValid.Should().BeTrue(); |
||||
|
||||
await _stripeFacade.Received(1).GetSubscription( |
||||
subscription.Id, |
||||
Arg.Any<SubscriptionGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
|
||||
[Fact] |
||||
public async Task ValidateCloudRegion_MetadataMiscasedRegion_ReturnsTrue() |
||||
{ |
||||
// Arrange |
||||
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); |
||||
|
||||
var subscription = Copy(stripeEvent.Data.Object as Subscription); |
||||
|
||||
var customer = await GetCustomerAsync(); |
||||
customer.Metadata = new Dictionary<string, string> |
||||
{ |
||||
{ "Region", "US" } |
||||
}; |
||||
|
||||
subscription.Customer = customer; |
||||
|
||||
_stripeFacade.GetSubscription( |
||||
subscription.Id, |
||||
Arg.Any<SubscriptionGetOptions>()) |
||||
.Returns(subscription); |
||||
|
||||
// Act |
||||
var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); |
||||
|
||||
// Assert |
||||
cloudRegionValid.Should().BeTrue(); |
||||
|
||||
await _stripeFacade.Received(1).GetSubscription( |
||||
subscription.Id, |
||||
Arg.Any<SubscriptionGetOptions>(), |
||||
Arg.Any<RequestOptions>(), |
||||
Arg.Any<CancellationToken>()); |
||||
} |
||||
#endregion |
||||
|
||||
private static T Copy<T>(T input) |
||||
{ |
||||
var copy = (T)Activator.CreateInstance(typeof(T)); |
||||
|
||||
var properties = input.GetType().GetProperties(); |
||||
|
||||
foreach (var property in properties) |
||||
{ |
||||
var value = property.GetValue(input); |
||||
copy! |
||||
.GetType() |
||||
.GetProperty(property.Name)! |
||||
.SetValue(copy, value); |
||||
} |
||||
|
||||
return copy; |
||||
} |
||||
|
||||
private static async Task<Customer> GetCustomerAsync() |
||||
=> (await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated)).Data.Object as Customer; |
||||
} |
||||
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
using System.Reflection; |
||||
|
||||
namespace Bit.Billing.Test.Utilities; |
||||
|
||||
public static class EmbeddedResourceReader |
||||
{ |
||||
public static async Task<string> ReadAsync(string resourceType, string fileName) |
||||
{ |
||||
var assembly = Assembly.GetExecutingAssembly(); |
||||
|
||||
await using var stream = assembly.GetManifestResourceStream($"Bit.Billing.Test.Resources.{resourceType}.{fileName}"); |
||||
|
||||
if (stream == null) |
||||
{ |
||||
throw new Exception($"Failed to retrieve manifest resource stream for file: {fileName}."); |
||||
} |
||||
|
||||
using var reader = new StreamReader(stream); |
||||
|
||||
return await reader.ReadToEndAsync(); |
||||
} |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
using Stripe; |
||||
|
||||
namespace Bit.Billing.Test.Utilities; |
||||
|
||||
public enum StripeEventType |
||||
{ |
||||
ChargeSucceeded, |
||||
CustomerSubscriptionUpdated, |
||||
CustomerUpdated, |
||||
InvoiceCreated, |
||||
InvoiceUpcoming, |
||||
PaymentMethodAttached |
||||
} |
||||
|
||||
public static class StripeTestEvents |
||||
{ |
||||
public static async Task<Event> GetAsync(StripeEventType eventType) |
||||
{ |
||||
var fileName = eventType switch |
||||
{ |
||||
StripeEventType.ChargeSucceeded => "charge.succeeded.json", |
||||
StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json", |
||||
StripeEventType.CustomerUpdated => "customer.updated.json", |
||||
StripeEventType.InvoiceCreated => "invoice.created.json", |
||||
StripeEventType.InvoiceUpcoming => "invoice.upcoming.json", |
||||
StripeEventType.PaymentMethodAttached => "payment_method.attached.json" |
||||
}; |
||||
|
||||
var resource = await EmbeddedResourceReader.ReadAsync("Events", fileName); |
||||
|
||||
return EventUtility.ParseEvent(resource); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue