1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 07:36:14 -05:00

[PM-212] Sync Organization Billing Email from Stripe Webhook (#3305)

* Add StripeFacade and StripeEventService

* Add StripeEventServiceTests

* Handle customer.updated event in StripeController
This commit is contained in:
Alex Morask
2023-10-11 15:57:51 -04:00
committed by GitHub
parent 3a71e7b081
commit b2af73f00f
19 changed files with 2316 additions and 214 deletions

View File

@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
@ -24,4 +25,25 @@
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\Events\charge.succeeded.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\customer.subscription.updated.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\customer.updated.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\invoice.created.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\invoice.upcoming.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\payment_method.attached.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -18,6 +18,15 @@
"resolved": "3.1.2",
"contentHash": "wuLDIDKD5XMt0A7lE31JPenT7QQwZPFkP5rRpdJeblyXZ9MGLI8rYjvm5fvAKln+2/X+4IxxQDxBtwdrqKNLZw=="
},
"FluentAssertions": {
"type": "Direct",
"requested": "[6.12.0, )",
"resolved": "6.12.0",
"contentHash": "ZXhHT2YwP9lajrwSKbLlFqsmCCvFJMoRSK9t7sImfnCyd0OB3MhgxdoMcVqxbq1iyxD6mD2fiackWmBb7ayiXQ==",
"dependencies": {
"System.Configuration.ConfigurationManager": "4.4.0"
}
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.1.0, )",