diff --git a/PatchNotes.Api/PatchNotes.Api.csproj b/PatchNotes.Api/PatchNotes.Api.csproj
index d5c7a7d7..c8da4948 100644
--- a/PatchNotes.Api/PatchNotes.Api.csproj
+++ b/PatchNotes.Api/PatchNotes.Api.csproj
@@ -6,6 +6,12 @@
enable
+
+
+
+
+
diff --git a/PatchNotes.Api/Routes/SubscriptionRoutes.cs b/PatchNotes.Api/Routes/SubscriptionRoutes.cs
index 73651617..59cdfdd1 100644
--- a/PatchNotes.Api/Routes/SubscriptionRoutes.cs
+++ b/PatchNotes.Api/Routes/SubscriptionRoutes.cs
@@ -83,6 +83,14 @@ public static WebApplication MapSubscriptionRoutes(this WebApplication app)
// Determine the base URL for success/cancel redirects
var origin = GetValidatedOrigin(httpContext, configuration);
+ // One dictionary for both: the session tag and the subscription tag have to agree,
+ // and building it twice is how they would come to disagree.
+ var appMetadata = new Dictionary
+ {
+ { "stytch_user_id", stytchUserId },
+ { "app", "patchnotes" },
+ };
+
var sessionOptions = new SessionCreateOptions
{
Mode = "subscription",
@@ -98,11 +106,11 @@ public static WebApplication MapSubscriptionRoutes(this WebApplication app)
SuccessUrl = $"{origin}/subscription-success?session_id={{CHECKOUT_SESSION_ID}}",
CancelUrl = $"{origin}/subscription-canceled",
CustomerEmail = user.Email,
- Metadata = new Dictionary
- {
- { "stytch_user_id", stytchUserId },
- { "app", "patchnotes" },
- },
+ Metadata = appMetadata,
+ // Stripe does not copy the session's metadata onto the subscription it creates,
+ // so the tag is set again here. Without it the subscription and its invoices
+ // arrive at our webhook carrying nothing that identifies them as ours.
+ SubscriptionData = new SessionSubscriptionDataOptions { Metadata = appMetadata },
};
// If user already has a Stripe customer ID, use it
diff --git a/PatchNotes.Api/Webhooks/StripeWebhook.cs b/PatchNotes.Api/Webhooks/StripeWebhook.cs
index 3fae56ab..ae8725d4 100644
--- a/PatchNotes.Api/Webhooks/StripeWebhook.cs
+++ b/PatchNotes.Api/Webhooks/StripeWebhook.cs
@@ -52,61 +52,68 @@ public static WebApplication MapStripeWebhook(this WebApplication app)
return Results.Ok(new { received = true, duplicate = true });
}
- // Filter events to only those for our app
- if (stripeEvent.Data.Object is IHasMetadata objWithMetadata)
+ // The only global check is a negative one: reject what is provably another app's.
+ //
+ // There is deliberately no positive "this looks like ours" gate. Metadata alone misses
+ // invoices and any subscription predating subscription_data.metadata, and widening it
+ // with "the customer exists in our Users table" trades a strong signal for a weak one
+ // -- a stored customer is not proof the event concerns us -- while giving handlers a
+ // false assurance that admission had already been decided for them. Each handler below
+ // resolves its own subject and returns false without writing when it cannot.
+ if (IsTaggedForAnotherApp(stripeEvent.Data.Object))
{
- var metadata = objWithMetadata.Metadata;
- if (metadata == null || !metadata.TryGetValue("app", out var appValue) || appValue != "patchnotes")
- {
- // Not our event, ignore but acknowledge
- return Results.Ok(new { received = true, ignored = true });
- }
- }
- else
- {
- // Unknown object type without metadata — skip to be safe
- logger.LogWarning("Stripe event {EventType} data object does not support metadata, skipping", stripeEvent.Type);
+ logger.LogInformation(
+ "Ignoring Stripe event {EventId} ({EventType}): tagged for another app",
+ stripeEvent.Id, stripeEvent.Type);
return Results.Ok(new { received = true, ignored = true });
}
try
{
+ bool applied;
switch (stripeEvent.Type)
{
case "checkout.session.completed":
- await HandleCheckoutSessionCompleted(stripeEvent, db, logger);
+ applied = await HandleCheckoutSessionCompleted(stripeEvent, db, logger);
break;
case "customer.subscription.updated":
- await HandleSubscriptionUpdated(stripeEvent, db, logger);
+ applied = await HandleSubscriptionUpdated(stripeEvent, db, logger);
break;
case "customer.subscription.deleted":
- await HandleSubscriptionDeleted(stripeEvent, db, logger);
+ applied = await HandleSubscriptionDeleted(stripeEvent, db, logger);
break;
case "invoice.payment_failed":
- await HandlePaymentFailed(stripeEvent, db, logger);
+ applied = await HandlePaymentFailed(stripeEvent, db, logger);
break;
case "invoice.payment_succeeded":
- await HandlePaymentSucceeded(stripeEvent, db, logger);
+ applied = await HandlePaymentSucceeded(stripeEvent, db, logger);
break;
default:
logger.LogInformation("Unhandled Stripe event type: {EventType}", stripeEvent.Type);
+ applied = false;
break;
}
- // Record event as processed for idempotency
- db.ProcessedWebhookEvents.Add(new ProcessedWebhookEvent
+ // Only events that actually changed something are recorded. This table means "do
+ // not process this again", and claiming that for an event no handler applied burns
+ // it permanently: a subscription event that arrives before we can resolve its user
+ // would be consumed on first delivery and could never be replayed.
+ if (applied)
{
- EventId = stripeEvent.Id,
- ProcessedAt = DateTimeOffset.UtcNow
- });
- await db.SaveChangesAsync();
+ db.ProcessedWebhookEvents.Add(new ProcessedWebhookEvent
+ {
+ EventId = stripeEvent.Id,
+ ProcessedAt = DateTimeOffset.UtcNow
+ });
+ await db.SaveChangesAsync();
+ }
- return Results.Ok(new { received = true });
+ return Results.Ok(new { received = true, applied });
}
catch (StripeException ex)
{
@@ -124,23 +131,142 @@ public static WebApplication MapStripeWebhook(this WebApplication app)
return app;
}
- private static async Task HandleCheckoutSessionCompleted(Event stripeEvent, PatchNotesDbContext db, ILogger logger)
+ private const string AppMetadataKey = "app";
+ private const string AppMetadataValue = "patchnotes";
+ private const string StytchUserIdMetadataKey = "stytch_user_id";
+
+ ///
+ /// Subscription statuses that mean the customer is actually on this subscription. Anything
+ /// outside this set is history, which is what distinguishes a replacement subscription from a
+ /// stale echo of a previous one.
+ ///
+ private static readonly HashSet LiveSubscriptionStatuses =
+ new(StringComparer.Ordinal) { "active", "trialing", "past_due", "unpaid", "incomplete" };
+
+ ///
+ /// True only when the event carries an explicit app tag naming someone else. An absent tag is
+ /// not evidence either way -- invoices never carry one -- so it is not treated as rejection.
+ ///
+ private static bool IsTaggedForAnotherApp(object? data) =>
+ data is IHasMetadata { Metadata: { } metadata }
+ && metadata.TryGetValue(AppMetadataKey, out var app)
+ && app != AppMetadataValue;
+
+ ///
+ /// Re-reads the subscription from Stripe. Snapshot payloads are eventually consistent, so the
+ /// payload identifies the subscription but never decides its state.
+ ///
+ private static Task FetchCurrentAsync(string subscriptionId) =>
+ new SubscriptionService().GetAsync(subscriptionId);
+
+ ///
+ /// The user a subscription event concerns. The customer id is the usual route, but Stripe can
+ /// deliver subscription events before checkout.session.completed has stored one, so the
+ /// stytch_user_id written to subscription_data.metadata at checkout is the fallback.
+ ///
+ private static async Task ResolveUserAsync(Subscription payload, PatchNotesDbContext db)
+ {
+ if (!string.IsNullOrEmpty(payload.CustomerId))
+ {
+ var byCustomer = await db.Users
+ .FirstOrDefaultAsync(u => u.StripeCustomerId == payload.CustomerId);
+ if (byCustomer != null)
+ {
+ return byCustomer;
+ }
+ }
+
+ if (payload.Metadata is { } metadata
+ && metadata.TryGetValue(StytchUserIdMetadataKey, out var stytchUserId)
+ && !string.IsNullOrEmpty(stytchUserId))
+ {
+ return await db.Users.FirstOrDefaultAsync(u => u.StytchUserId == stytchUserId);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Whether an event about this subscription may write the user's subscription state.
+ ///
+ /// The stored id is not a high-water mark: only checkout advances it and cancellation leaves it
+ /// pointing at the dead subscription, so "a different id" cannot be read as "an older one". A
+ /// resubscribe would be misfiled as stale on that reading. The subscription's own status is the
+ /// signal that works -- a live subscription is the one the customer is on.
+ ///
+ internal static bool ShouldAdopt(User user, Subscription subscription, ILogger logger)
+ {
+ if (string.IsNullOrEmpty(user.StripeSubscriptionId)
+ || user.StripeSubscriptionId == subscription.Id
+ || LiveSubscriptionStatuses.Contains(subscription.Status))
+ {
+ return true;
+ }
+
+ logger.LogInformation(
+ "Ignoring {Status} subscription {EventSubscriptionId}; user is on {CurrentSubscriptionId}",
+ subscription.Status, subscription.Id, user.StripeSubscriptionId);
+ return false;
+ }
+
+ ///
+ /// Whether an invoice belongs to the subscription the user is currently on. Stripe retries a
+ /// failed invoice for weeks, so a replaced subscription keeps producing invoice events long
+ /// after the customer resubscribed; acting on those reports dunning for an account in good
+ /// standing, or rewinds the paid period to the old subscription's. An invoice with no
+ /// subscription is not ours to act on either.
+ ///
+ internal static bool IsForCurrentSubscription(
+ User user, Invoice invoice, ILogger logger, string action)
+ {
+ var invoiceSubscriptionId = invoice.Parent?.SubscriptionDetails?.SubscriptionId;
+
+ if (!string.IsNullOrEmpty(invoiceSubscriptionId)
+ && invoiceSubscriptionId == user.StripeSubscriptionId)
+ {
+ return true;
+ }
+
+ logger.LogInformation(
+ "Ignoring {Action} for invoice {InvoiceId} on subscription {InvoiceSubscriptionId}; user is on {CurrentSubscriptionId}",
+ action, invoice.Id, invoiceSubscriptionId ?? "(none)", user.StripeSubscriptionId ?? "(none)");
+ return false;
+ }
+
+ ///
+ /// Copies subscription state onto the user. The expiry is overwritten only when the
+ /// subscription carries one: Items can come back empty, and reads a
+ /// null expiry as "no paid period remaining", so writing null ends access immediately.
+ ///
+ private static void ApplySubscription(User user, Subscription subscription)
{
- var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
- if (session == null) return;
+ user.StripeSubscriptionId = subscription.Id;
+ user.SubscriptionStatus = subscription.Status;
+ user.SubscriptionExpiresAt =
+ subscription.Items.Data.FirstOrDefault()?.CurrentPeriodEnd ?? user.SubscriptionExpiresAt;
+ }
- // Get the Stytch user ID from session metadata
- if (!session.Metadata.TryGetValue("stytch_user_id", out var stytchUserId))
+ private static async Task HandleCheckoutSessionCompleted(
+ Event stripeEvent, PatchNotesDbContext db, ILogger logger)
+ {
+ if (stripeEvent.Data.Object is not Stripe.Checkout.Session session) return false;
+
+ // Metadata is not guaranteed: nothing upstream vets it, so the check lives at the point
+ // of use rather than being assumed from an admission filter.
+ if (session.Metadata is not { } metadata
+ || !metadata.TryGetValue(StytchUserIdMetadataKey, out var stytchUserId))
{
- logger.LogWarning("Checkout session completed but no stytch_user_id in metadata");
- return;
+ logger.LogWarning(
+ "Checkout session {SessionId} completed with no stytch_user_id in metadata",
+ session.Id);
+ return false;
}
var user = await db.Users.FirstOrDefaultAsync(u => u.StytchUserId == stytchUserId);
if (user == null)
{
logger.LogWarning("User not found for Stytch ID: {StytchUserId}", stytchUserId);
- return;
+ return false;
}
// Update user with Stripe customer ID
@@ -149,100 +275,119 @@ private static async Task HandleCheckoutSessionCompleted(Event stripeEvent, Patc
// Fetch the subscription to get status and period end
if (!string.IsNullOrEmpty(session.SubscriptionId))
{
- var subscriptionService = new SubscriptionService();
- var subscription = await subscriptionService.GetAsync(session.SubscriptionId);
-
- user.StripeSubscriptionId = subscription.Id;
- user.SubscriptionStatus = subscription.Status;
- user.SubscriptionExpiresAt = subscription.Items.Data.FirstOrDefault()?.CurrentPeriodEnd;
+ ApplySubscription(user, await FetchCurrentAsync(session.SubscriptionId));
}
await db.SaveChangesAsync();
logger.LogInformation("Updated subscription for user {StytchUserId}: status={Status}", stytchUserId, user.SubscriptionStatus);
+ return true;
}
- private static async Task HandleSubscriptionUpdated(Event stripeEvent, PatchNotesDbContext db, ILogger logger)
+ private static async Task HandleSubscriptionUpdated(
+ Event stripeEvent, PatchNotesDbContext db, ILogger logger)
{
- var subscription = stripeEvent.Data.Object as Subscription;
- if (subscription == null) return;
+ if (stripeEvent.Data.Object is not Subscription payload) return false;
- var user = await db.Users.FirstOrDefaultAsync(u => u.StripeCustomerId == subscription.CustomerId);
+ var user = await ResolveUserAsync(payload, db);
if (user == null)
{
- logger.LogWarning("User not found for Stripe customer: {CustomerId}", subscription.CustomerId);
- return;
+ logger.LogWarning("User not found for Stripe customer: {CustomerId}", payload.CustomerId);
+ return false;
}
- user.StripeSubscriptionId = subscription.Id;
- user.SubscriptionStatus = subscription.Status;
- user.SubscriptionExpiresAt = subscription.Items.Data.FirstOrDefault()?.CurrentPeriodEnd;
+ var subscription = await FetchCurrentAsync(payload.Id);
+ if (!ShouldAdopt(user, subscription, logger)) return false;
+
+ // Reached via subscription metadata when checkout has not landed yet, so the customer id
+ // may still be missing.
+ if (string.IsNullOrEmpty(user.StripeCustomerId))
+ {
+ user.StripeCustomerId = subscription.CustomerId;
+ }
+
+ ApplySubscription(user, subscription);
await db.SaveChangesAsync();
logger.LogInformation("Updated subscription for customer {CustomerId}: status={Status}", subscription.CustomerId, subscription.Status);
+ return true;
}
- private static async Task HandleSubscriptionDeleted(Event stripeEvent, PatchNotesDbContext db, ILogger logger)
+ private static async Task HandleSubscriptionDeleted(
+ Event stripeEvent, PatchNotesDbContext db, ILogger logger)
{
- var subscription = stripeEvent.Data.Object as Subscription;
- if (subscription == null) return;
+ if (stripeEvent.Data.Object is not Subscription payload) return false;
- var user = await db.Users.FirstOrDefaultAsync(u => u.StripeCustomerId == subscription.CustomerId);
+ var user = await ResolveUserAsync(payload, db);
if (user == null)
{
- logger.LogWarning("User not found for Stripe customer: {CustomerId}", subscription.CustomerId);
- return;
+ logger.LogWarning("User not found for Stripe customer: {CustomerId}", payload.CustomerId);
+ return false;
}
+ // A cancellation only concerns the subscription the user is actually on.
+ if (!string.IsNullOrEmpty(user.StripeSubscriptionId)
+ && user.StripeSubscriptionId != payload.Id)
+ {
+ logger.LogInformation(
+ "Ignoring cancellation of subscription {EventSubscriptionId}; user is on {CurrentSubscriptionId}",
+ payload.Id, user.StripeSubscriptionId);
+ return false;
+ }
+
+ // No re-fetch here. The status is already known, and the period end is in the payload that
+ // was signature-verified, so re-reading would only add a dependency on Stripe being
+ // reachable for a cancellation to be recorded at all.
user.SubscriptionStatus = "canceled";
// Keep the expiration date so user has access until end of paid period
- user.SubscriptionExpiresAt = subscription.Items.Data.FirstOrDefault()?.CurrentPeriodEnd;
+ user.SubscriptionExpiresAt =
+ payload.Items.Data.FirstOrDefault()?.CurrentPeriodEnd ?? user.SubscriptionExpiresAt;
await db.SaveChangesAsync();
- logger.LogInformation("Subscription canceled for customer {CustomerId}", subscription.CustomerId);
+ logger.LogInformation("Subscription canceled for customer {CustomerId}", payload.CustomerId);
+ return true;
}
- private static async Task HandlePaymentFailed(Event stripeEvent, PatchNotesDbContext db, ILogger logger)
+ private static async Task HandlePaymentFailed(
+ Event stripeEvent, PatchNotesDbContext db, ILogger logger)
{
- var invoice = stripeEvent.Data.Object as Invoice;
- if (invoice == null) return;
+ if (stripeEvent.Data.Object is not Invoice invoice) return false;
var user = await db.Users.FirstOrDefaultAsync(u => u.StripeCustomerId == invoice.CustomerId);
if (user == null)
{
logger.LogWarning("User not found for Stripe customer: {CustomerId}", invoice.CustomerId);
- return;
+ return false;
}
+ if (!IsForCurrentSubscription(user, invoice, logger, "payment failure")) return false;
+
user.SubscriptionStatus = "past_due";
await db.SaveChangesAsync();
logger.LogWarning("Payment failed for customer {CustomerId}, marked as past_due", invoice.CustomerId);
+ return true;
}
- private static async Task HandlePaymentSucceeded(Event stripeEvent, PatchNotesDbContext db, ILogger logger)
+ private static async Task HandlePaymentSucceeded(
+ Event stripeEvent, PatchNotesDbContext db, ILogger logger)
{
- var invoice = stripeEvent.Data.Object as Invoice;
- if (invoice == null) return;
+ if (stripeEvent.Data.Object is not Invoice invoice) return false;
var user = await db.Users.FirstOrDefaultAsync(u => u.StripeCustomerId == invoice.CustomerId);
if (user == null)
{
logger.LogWarning("User not found for Stripe customer: {CustomerId}", invoice.CustomerId);
- return;
+ return false;
}
- // Update subscription expiry on successful renewal payment
- var invoiceSubscriptionId = invoice.Parent?.SubscriptionDetails?.SubscriptionId;
- if (!string.IsNullOrEmpty(invoiceSubscriptionId))
- {
- var subscriptionService = new SubscriptionService();
- var subscription = await subscriptionService.GetAsync(invoiceSubscriptionId);
+ if (!IsForCurrentSubscription(user, invoice, logger, "payment success")) return false;
- user.SubscriptionStatus = subscription.Status;
- user.SubscriptionExpiresAt = subscription.Items.Data.FirstOrDefault()?.CurrentPeriodEnd;
+ // Update subscription expiry on successful renewal payment
+ ApplySubscription(
+ user, await FetchCurrentAsync(invoice.Parent!.SubscriptionDetails!.SubscriptionId));
- await db.SaveChangesAsync();
- logger.LogInformation("Payment succeeded for customer {CustomerId}, updated expiry to {ExpiresAt}", invoice.CustomerId, user.SubscriptionExpiresAt);
- }
+ await db.SaveChangesAsync();
+ logger.LogInformation("Payment succeeded for customer {CustomerId}, updated expiry to {ExpiresAt}", invoice.CustomerId, user.SubscriptionExpiresAt);
+ return true;
}
}
diff --git a/PatchNotes.Tests/PatchNotesApiFixture.cs b/PatchNotes.Tests/PatchNotesApiFixture.cs
index 88a7d433..08db5b2e 100644
--- a/PatchNotes.Tests/PatchNotesApiFixture.cs
+++ b/PatchNotes.Tests/PatchNotesApiFixture.cs
@@ -1,6 +1,7 @@
using System.Data.Common;
using System.Net;
using Microsoft.AspNetCore.Hosting;
+using Stripe;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@@ -98,6 +99,15 @@ public async Task InitializeAsync()
{
// Force creation of the WebApplication and database
using var scope = Services.CreateScope();
+
+ // Nothing under test may reach Stripe. SubscriptionService is constructed inline in the
+ // webhook handlers and cannot be faked, so the SDK is pointed at a closed loopback port:
+ // a regression that reintroduces a call then fails immediately and locally, instead of
+ // dialling api.stripe.com from CI and reporting a 503 in place of the real assertion.
+ // This runs after the host is built because Program.cs assigns StripeConfiguration.ApiKey,
+ // which discards any client set before it.
+ StripeConfiguration.StripeClient = new StripeClient(
+ "sk_test_placeholder", null, null, "http://127.0.0.1:1");
var db = scope.ServiceProvider.GetRequiredService();
await db.Database.EnsureCreatedAsync();
}
diff --git a/PatchNotes.Tests/StripeWebhookTests.cs b/PatchNotes.Tests/StripeWebhookTests.cs
new file mode 100644
index 00000000..cbb21baf
--- /dev/null
+++ b/PatchNotes.Tests/StripeWebhookTests.cs
@@ -0,0 +1,455 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+using FluentAssertions;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using PatchNotes.Api.Webhooks;
+using PatchNotes.Data;
+using Stripe;
+
+namespace PatchNotes.Tests;
+
+public class StripeWebhookTests : IAsyncLifetime
+{
+ private const string TestWebhookSecret = "whsec_testsecretvalueforsigning";
+ private const string KnownCustomerId = "cus_known123";
+ private const string CurrentSubscriptionId = "sub_current";
+ private const long PeriodEndUnix = 1798761600; // 2027-01-01T00:00:00Z
+ private static readonly DateTimeOffset PeriodEnd =
+ DateTimeOffset.FromUnixTimeSeconds(PeriodEndUnix);
+
+ private PatchNotesApiFixture _fixture = null!;
+ private HttpClient _client = null!;
+
+ public async Task InitializeAsync()
+ {
+ _fixture = new PatchNotesApiFixture();
+ _fixture.ConfigureSettings(builder =>
+ {
+ builder.UseSetting("Stripe:WebhookSecret", TestWebhookSecret);
+ });
+ await _fixture.InitializeAsync();
+ _client = _fixture.CreateClient();
+ }
+
+ public async Task DisposeAsync()
+ {
+ _client.Dispose();
+ await _fixture.DisposeAsync();
+ _fixture.Dispose();
+ }
+
+ #region Admission
+
+ [Fact]
+ public async Task HandleWebhook_GivenUntaggedInvoiceForCurrentSubscription_IsApplied()
+ {
+ // The original bug. Invoices never carry our "app" metadata, because Stripe propagates it
+ // neither from the Checkout Session nor from the subscription, so dropping untagged events
+ // silently discarded every renewal and payment failure.
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ var response = await PostEventAsync("invoice.payment_failed", InvoiceJson());
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await response.Content.ReadAsStringAsync()).Should().Contain("\"applied\":true");
+ (await GetUserAsync())!.SubscriptionStatus.Should().Be("past_due");
+ }
+
+ [Fact]
+ public async Task HandleWebhook_GivenForeignAppTag_IsIgnoredBeforeAnyHandler()
+ {
+ // The only global check left, and the only one that is actual evidence: an explicit tag
+ // naming a different app.
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ var response = await PostEventAsync("invoice.payment_failed",
+ InvoiceJson(metadata: """{ "app": "someoneelse" }"""));
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await response.Content.ReadAsStringAsync()).Should().Contain("ignored");
+ (await GetUserAsync())!.SubscriptionStatus.Should().Be("active");
+ }
+
+ [Fact]
+ public async Task HandleWebhook_GivenInvoiceForUnknownCustomer_ChangesNothing()
+ {
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ var response = await PostEventAsync("invoice.payment_failed",
+ InvoiceJson(customerId: "cus_someoneelse"));
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await response.Content.ReadAsStringAsync()).Should().Contain("\"applied\":false");
+ (await GetUserAsync())!.SubscriptionStatus.Should().Be("active");
+ }
+
+ #endregion
+
+ #region Recording only what was applied
+
+ [Fact]
+ public async Task HandleWebhook_GivenUnappliedEvent_IsNotRecordedSoItStaysReplayable()
+ {
+ // A row here means "never process this again". Claiming that for an event no handler acted
+ // on burns it: one that arrives before its user is resolvable would be consumed on first
+ // delivery and could never be replayed from the dashboard.
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ await PostEventAsync("invoice.payment_failed", InvoiceJson(customerId: "cus_someoneelse"));
+
+ (await CountRecordedAsync()).Should().Be(0);
+ }
+
+ [Fact]
+ public async Task HandleWebhook_GivenAppliedEvent_IsRecorded()
+ {
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ await PostEventAsync("invoice.payment_failed", InvoiceJson());
+
+ (await CountRecordedAsync()).Should().Be(1);
+ }
+
+ #endregion
+
+ #region Invoices belong to a subscription
+
+ [Fact]
+ public async Task HandleWebhook_GivenInvoiceForReplacedSubscription_IsIgnored()
+ {
+ // Stripe retries a failed invoice for weeks. A customer who cancelled sub_old with an open
+ // invoice and resubscribed would otherwise be flipped to past_due by every retry against
+ // the dead subscription, reporting dunning for an account in good standing.
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ var response = await PostEventAsync("invoice.payment_failed",
+ InvoiceJson(subscriptionId: "sub_old"));
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await GetUserAsync())!.SubscriptionStatus.Should().Be("active");
+ (await CountRecordedAsync()).Should().Be(0);
+ }
+
+ [Fact]
+ public async Task HandleWebhook_GivenInvoiceWithNoSubscription_IsIgnored()
+ {
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ var response = await PostEventAsync("invoice.payment_failed",
+ InvoiceJson(subscriptionId: null));
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await GetUserAsync())!.SubscriptionStatus.Should().Be("active");
+ }
+
+ #endregion
+
+ #region Cancellation
+
+ [Fact]
+ public async Task HandleWebhook_GivenDeleteForCurrentSubscription_KeepsThePaidPeriod()
+ {
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ var response = await PostEventAsync("customer.subscription.deleted", SubscriptionJson());
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var user = await GetUserAsync();
+ user!.SubscriptionStatus.Should().Be("canceled");
+ user.SubscriptionExpiresAt.Should().Be(PeriodEnd);
+ }
+
+ [Fact]
+ public async Task HandleWebhook_GivenDeleteWithNoPeriodEnd_KeepsTheExistingExpiry()
+ {
+ // IsPro reads a null expiry as "no paid period remaining", so overwriting unconditionally
+ // would end a cancelled user's access the moment a payload came back without items.
+ var existing = DateTimeOffset.UtcNow.AddDays(20);
+ await SeedUserAsync(
+ status: "active", subscriptionId: CurrentSubscriptionId, expiresAt: existing);
+
+ await PostEventAsync("customer.subscription.deleted", SubscriptionJson(items: "[]"));
+
+ var user = await GetUserAsync();
+ user!.SubscriptionStatus.Should().Be("canceled");
+ user.SubscriptionExpiresAt.Should().BeCloseTo(existing, TimeSpan.FromSeconds(1));
+ }
+
+ [Fact]
+ public async Task HandleWebhook_GivenDeleteForSupersededSubscription_LeavesUserAlone()
+ {
+ // A delayed cancellation for a subscription the user already replaced must not cancel the
+ // current one, and must stay replayable rather than being marked processed.
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ var response = await PostEventAsync("customer.subscription.deleted",
+ SubscriptionJson(subscriptionId: "sub_old"));
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var user = await GetUserAsync();
+ user!.SubscriptionStatus.Should().Be("active");
+ user.StripeSubscriptionId.Should().Be(CurrentSubscriptionId);
+ (await CountRecordedAsync()).Should().Be(0);
+ }
+
+ #endregion
+
+ #region Checkout sessions
+
+ [Fact]
+ public async Task HandleWebhook_GivenSessionWithoutMetadata_IsHandledNotCrashed()
+ {
+ // Nothing upstream guarantees metadata, so the handler has to tolerate its absence.
+ // Throwing here returns 500 and Stripe retries until it disables the endpoint.
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ var response = await PostEventAsync("checkout.session.completed", $$"""
+ {
+ "id": "cs_nometa",
+ "object": "checkout.session",
+ "customer": "{{KnownCustomerId}}"
+ }
+ """);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await GetUserAsync())!.SubscriptionStatus.Should().Be("active");
+ }
+
+ #endregion
+
+ #region Admission predicates
+
+ // ShouldAdopt and IsForCurrentSubscription decide whether an event may write user state. Both
+ // sit behind a Stripe API call in their handlers, so they are exercised directly rather than
+ // through the endpoint, which would need live network.
+
+ [Fact]
+ public void ShouldAdopt_GivenReplacementSubscriptionAfterCancellation_AdoptsIt()
+ {
+ // The stored id is not a high-water mark: cancellation leaves it pointing at the dead
+ // subscription. Reading "different id" as "older" would misfile a resubscribe as stale and
+ // strand the account on the cancelled subscription.
+ var user = NewUser(subscriptionId: "sub_old", status: "canceled");
+
+ StripeWebhook.ShouldAdopt(user, new Subscription { Id = "sub_new", Status = "active" },
+ NullLogger.Instance).Should().BeTrue();
+ }
+
+ [Fact]
+ public void ShouldAdopt_GivenStaleEchoOfAReplacedSubscription_DeclinesIt()
+ {
+ var user = NewUser(subscriptionId: CurrentSubscriptionId, status: "active");
+
+ StripeWebhook.ShouldAdopt(user, new Subscription { Id = "sub_old", Status = "canceled" },
+ NullLogger.Instance).Should().BeFalse();
+ }
+
+ [Theory]
+ [InlineData("active")]
+ [InlineData("trialing")]
+ [InlineData("past_due")]
+ public void ShouldAdopt_GivenAnyLiveStatus_AdoptsIt(string status)
+ {
+ var user = NewUser(subscriptionId: "sub_old", status: "canceled");
+
+ StripeWebhook.ShouldAdopt(user, new Subscription { Id = "sub_new", Status = status },
+ NullLogger.Instance).Should().BeTrue();
+ }
+
+ [Fact]
+ public void ShouldAdopt_GivenUserWithNoSubscriptionYet_AdoptsIt()
+ {
+ StripeWebhook.ShouldAdopt(NewUser(), new Subscription { Id = "sub_new", Status = "canceled" },
+ NullLogger.Instance).Should().BeTrue();
+ }
+
+ [Fact]
+ public void IsForCurrentSubscription_GivenInvoiceForAnotherSubscription_IsFalse()
+ {
+ var user = NewUser(subscriptionId: CurrentSubscriptionId);
+
+ StripeWebhook.IsForCurrentSubscription(
+ user, InvoiceFor("sub_old"), NullLogger.Instance, "x").Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsForCurrentSubscription_GivenInvoiceWithNoSubscription_IsFalse()
+ {
+ var user = NewUser(subscriptionId: CurrentSubscriptionId);
+
+ StripeWebhook.IsForCurrentSubscription(
+ user, new Invoice { Id = "in_x" }, NullLogger.Instance, "x").Should().BeFalse();
+ }
+
+ [Fact]
+ public void IsForCurrentSubscription_GivenMatchingSubscription_IsTrue()
+ {
+ var user = NewUser(subscriptionId: CurrentSubscriptionId);
+
+ StripeWebhook.IsForCurrentSubscription(
+ user, InvoiceFor(CurrentSubscriptionId), NullLogger.Instance, "x").Should().BeTrue();
+ }
+
+ #endregion
+
+ #region Transport
+
+ [Fact]
+ public async Task HandleWebhook_GivenInvalidSignature_ReturnsBadRequest()
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/stripe")
+ {
+ Content = new StringContent("{}", Encoding.UTF8, "application/json"),
+ };
+ request.Headers.TryAddWithoutValidation("Stripe-Signature", "t=123,v1=deadbeef");
+
+ var response = await _client.SendAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task HandleWebhook_GivenDuplicateEventId_ReturnsDuplicateFlag()
+ {
+ await SeedUserAsync(status: "active", subscriptionId: CurrentSubscriptionId);
+
+ var first = await PostEventAsync(
+ "invoice.payment_failed", InvoiceJson(), eventId: "evt_dup");
+ first.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var second = await PostEventAsync(
+ "invoice.payment_failed", InvoiceJson(), eventId: "evt_dup");
+
+ second.StatusCode.Should().Be(HttpStatusCode.OK);
+ (await second.Content.ReadAsStringAsync()).Should().Contain("duplicate");
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static User NewUser(string? subscriptionId = null, string? status = null) => new()
+ {
+ StytchUserId = "stytch_user",
+ Email = "user@example.com",
+ StripeCustomerId = KnownCustomerId,
+ StripeSubscriptionId = subscriptionId,
+ SubscriptionStatus = status,
+ };
+
+ private static string InvoiceJson(
+ string customerId = KnownCustomerId,
+ string? subscriptionId = CurrentSubscriptionId,
+ string metadata = "{}")
+ {
+ var parent = subscriptionId is null
+ ? "null"
+ : $$"""{ "type": "subscription_details", "subscription_details": { "subscription": "{{subscriptionId}}" } }""";
+
+ return $$"""
+ {
+ "id": "in_test",
+ "object": "invoice",
+ "customer": "{{customerId}}",
+ "metadata": {{metadata}},
+ "parent": {{parent}}
+ }
+ """;
+ }
+
+ private static string SubscriptionJson(
+ string subscriptionId = CurrentSubscriptionId,
+ string? items = null)
+ {
+ items ??= $$"""[ { "id": "si_1", "object": "subscription_item", "current_period_end": {{PeriodEndUnix}} } ]""";
+
+ return $$"""
+ {
+ "id": "{{subscriptionId}}",
+ "object": "subscription",
+ "customer": "{{KnownCustomerId}}",
+ "metadata": {},
+ "items": { "object": "list", "data": {{items}} }
+ }
+ """;
+ }
+
+ private static Invoice InvoiceFor(string subscriptionId) => new()
+ {
+ Id = "in_x",
+ Parent = new InvoiceParent
+ {
+ SubscriptionDetails = new InvoiceParentSubscriptionDetails
+ {
+ SubscriptionId = subscriptionId,
+ },
+ },
+ };
+
+ private Task PostEventAsync(
+ string type, string dataObject, string? eventId = null)
+ {
+ // api_version must match the SDK's, because ConstructEvent is called with
+ // throwOnApiVersionMismatch: true. Reading it at runtime keeps these tests working across
+ // SDK upgrades instead of pinning a version string that goes stale.
+ var body = $$"""
+ {
+ "id": "{{eventId ?? "evt_" + Guid.NewGuid().ToString("N")[..16]}}",
+ "object": "event",
+ "api_version": "{{StripeConfiguration.ApiVersion}}",
+ "created": {{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}},
+ "type": "{{type}}",
+ "livemode": false,
+ "pending_webhooks": 0,
+ "request": { "id": null, "idempotency_key": null },
+ "data": { "object": {{dataObject}} }
+ }
+ """;
+
+ var request = new HttpRequestMessage(HttpMethod.Post, "/webhooks/stripe")
+ {
+ Content = new StringContent(body, Encoding.UTF8, "application/json"),
+ };
+ request.Headers.TryAddWithoutValidation("Stripe-Signature", Sign(body));
+ return _client.SendAsync(request);
+ }
+
+ private static string Sign(string body)
+ {
+ var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
+ using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(TestWebhookSecret));
+ var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}"));
+ return $"t={timestamp},v1={Convert.ToHexString(hash).ToLowerInvariant()}";
+ }
+
+ private async Task SeedUserAsync(
+ string status, string? subscriptionId = null, DateTimeOffset? expiresAt = null)
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var user = NewUser(subscriptionId, status);
+ user.SubscriptionExpiresAt = expiresAt;
+ db.Users.Add(user);
+ await db.SaveChangesAsync();
+ }
+
+ private async Task GetUserAsync()
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.Users.FirstOrDefaultAsync(u => u.StripeCustomerId == KnownCustomerId);
+ }
+
+ private async Task CountRecordedAsync()
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.ProcessedWebhookEvents.CountAsync();
+ }
+
+ #endregion
+}