Skip to content

Commit b11d490

Browse files
committed
Add multi-source reconciliation to billing event ledger
1 parent 3fb9702 commit b11d490

23 files changed

Lines changed: 1039 additions & 96 deletions

application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ interface BillingEventsToolbarProps {
1717
}
1818

1919
// Order matches the BillingEventType enum so the dropdown reads in the same lifecycle order operators
20-
// see in our domain log (creation → renewal → upgrade → downgrade → cancellation → payment).
20+
// see in our domain log (creation → renewal → upgrade → downgrade → cancellation → payment → audit).
21+
// IMPORTANT: this list mirrors the C# BillingEventType enum (see application/account/Core/Features/
22+
// Subscriptions/Domain/BillingEvent.cs). Add new enum values here too — the enum's doc comment also
23+
// flags this requirement.
2124
const ALL_EVENT_TYPES: BillingEventType[] = [
2225
BillingEventType.SubscriptionCreated,
2326
BillingEventType.SubscriptionRenewed,
@@ -30,12 +33,15 @@ const ALL_EVENT_TYPES: BillingEventType[] = [
3033
BillingEventType.SubscriptionExpired,
3134
BillingEventType.SubscriptionImmediatelyCancelled,
3235
BillingEventType.SubscriptionSuspended,
36+
BillingEventType.SubscriptionPastDue,
3337
BillingEventType.PaymentFailed,
3438
BillingEventType.PaymentRecovered,
3539
BillingEventType.PaymentRefunded,
3640
BillingEventType.BillingInfoAdded,
3741
BillingEventType.BillingInfoUpdated,
38-
BillingEventType.PaymentMethodUpdated
42+
BillingEventType.PaymentMethodUpdated,
43+
BillingEventType.NoOp,
44+
BillingEventType.Unclassified
3945
];
4046

4147
export function BillingEventsToolbar({ search, eventTypes }: Readonly<BillingEventsToolbarProps>) {

application/account/BackOffice/shared/lib/api/labels.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export function getBillingEventTypeLabel(type: BillingEventType): string {
124124
return t`Cancelled immediately`;
125125
case BillingEventType.SubscriptionSuspended:
126126
return t`Suspended`;
127+
case BillingEventType.SubscriptionPastDue:
128+
return t`Past due`;
127129
case BillingEventType.PaymentFailed:
128130
return t`Payment failed`;
129131
case BillingEventType.PaymentRecovered:

application/account/BackOffice/shared/lib/billingEventStyle.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export const BILLING_EVENT_VARIANT: Record<BillingEventType, BillingEventVariant
7272
className: "bg-rose-500/10 text-rose-500 border-rose-500/20",
7373
icon: PauseCircleIcon
7474
},
75+
[BillingEventType.SubscriptionPastDue]: {
76+
className: "bg-amber-500/10 text-amber-600 border-amber-500/30",
77+
icon: CircleAlertIcon
78+
},
7579
[BillingEventType.PaymentFailed]: {
7680
className: "bg-rose-500/10 text-rose-500 border-rose-500/20",
7781
icon: CircleAlertIcon

application/account/BackOffice/shared/translations/locale/da-DK.po

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,9 @@ msgstr "Siden blev ikke fundet"
570570
msgid "Paid"
571571
msgstr "Betalt"
572572

573+
msgid "Past due"
574+
msgstr "Forfalden"
575+
573576
msgid "Payment failed"
574577
msgstr "Betaling mislykkedes"
575578

application/account/BackOffice/shared/translations/locale/en-US.po

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,9 @@ msgstr "Page not found"
570570
msgid "Paid"
571571
msgstr "Paid"
572572

573+
msgid "Past due"
574+
msgstr "Past due"
575+
573576
msgid "Payment failed"
574577
msgstr "Payment failed"
575578

application/account/Core/Database/Migrations/20260509120000_AddBillingEventsAndDriftDetection.cs renamed to application/account/Core/Database/Migrations/20260509180000_AddBillingEventsAndDriftDetection.cs

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,18 @@
44
namespace Account.Database.Migrations;
55

66
[DbContext(typeof(AccountDbContext))]
7-
[Migration("20260509120000_AddBillingEventsAndDriftDetection")]
7+
[Migration("20260509180000_AddBillingEventsAndDriftDetection")]
88
public sealed class AddBillingEventsAndDriftDetection : Migration
99
{
1010
protected override void Up(MigrationBuilder migrationBuilder)
1111
{
12-
// Subscription drift columns. IF NOT EXISTS so this migration is idempotent on staging where an
13-
// earlier iteration of this migration already added them. Removed before merging to main; new
14-
// environments only see plain ADD COLUMN.
15-
migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS subscribed_since timestamptz;");
16-
migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS scheduled_price_amount numeric(18,2);");
17-
migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS has_drift_detected boolean NOT NULL DEFAULT false;");
18-
migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS drift_checked_at timestamptz;");
19-
migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS drift_discrepancies jsonb NOT NULL DEFAULT '[]';");
12+
migrationBuilder.AddColumn<DateTimeOffset>("subscribed_since", "subscriptions", "timestamptz", nullable: true);
13+
migrationBuilder.AddColumn<decimal>("scheduled_price_amount", "subscriptions", "numeric(18,2)", nullable: true);
14+
migrationBuilder.AddColumn<bool>("has_drift_detected", "subscriptions", "boolean", nullable: false, defaultValue: false);
15+
migrationBuilder.AddColumn<DateTimeOffset>("drift_checked_at", "subscriptions", "timestamptz", nullable: true);
16+
migrationBuilder.AddColumn<string>("drift_discrepancies", "subscriptions", "jsonb", nullable: false, defaultValue: "[]");
2017

21-
migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS ix_subscriptions_has_drift_detected ON subscriptions (has_drift_detected) WHERE has_drift_detected = true;");
18+
migrationBuilder.CreateIndex("ix_subscriptions_has_drift_detected", "subscriptions", "has_drift_detected", filter: "has_drift_detected = true");
2219

2320
// Subscriptions created before this migration have no subscribed_since because the column did not
2421
// exist when the Basis -> paid transition occurred. Best available proxy for the start of their paid
@@ -55,25 +52,22 @@ WHERE jsonb_array_length(payment_transactions) > 0
5552
"""
5653
);
5754

58-
// Add the check constraint only if it doesn't exist. Removed before merging to main; new
59-
// environments only see a plain ALTER TABLE … ADD CONSTRAINT.
60-
migrationBuilder.Sql(
61-
"""
62-
DO $$
63-
BEGIN
64-
IF NOT EXISTS (
65-
SELECT 1 FROM pg_constraint
66-
WHERE conname = 'chk_subscriptions_payment_transactions_tax_breakdown'
67-
AND conrelid = 'subscriptions'::regclass
68-
) THEN
69-
ALTER TABLE subscriptions
70-
ADD CONSTRAINT chk_subscriptions_payment_transactions_tax_breakdown
71-
CHECK (NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))'));
72-
END IF;
73-
END $$;
74-
"""
55+
migrationBuilder.AddCheckConstraint(
56+
"chk_subscriptions_payment_transactions_tax_breakdown",
57+
"subscriptions",
58+
"""NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))')"""
7559
);
7660

61+
// The billing_events table is append-only. The unique index on stripe_event_id enforces strict
62+
// 1:1 with Stripe events: every recognized Stripe event yields exactly one row. Stripe's events.list
63+
// API has a 30-day retention window (see https://docs.stripe.com/api/events), so the local
64+
// stripe_events table is the authoritative source for replays beyond that window.
65+
// Hard rule: NO migration ever drops, deletes from, or truncates this table. Schema changes use
66+
// ALTER TABLE ADD/DROP COLUMN. Forensics and audit depend on full history being preserved.
67+
// tenant_id is the soft-scope query filter for ITenantScopedEntity; no FK to tenants because the
68+
// back-office is cross-tenant by design and uses IgnoreQueryFilters([QueryFilterNames.Tenant]).
69+
// modified_at is inherited from the framework's AggregateRoot shape and remains NULL by design —
70+
// billing_events is append-only forever (rows are never updated after insert).
7771
migrationBuilder.CreateTable(
7872
"billing_events",
7973
table => new
@@ -103,5 +97,20 @@ ADD CONSTRAINT chk_subscriptions_payment_transactions_tax_breakdown
10397
migrationBuilder.CreateIndex("ix_billing_events_tenant_id_occurred_at", "billing_events", ["tenant_id", "occurred_at"], descending: [false, true]);
10498
migrationBuilder.CreateIndex("ix_billing_events_occurred_at", "billing_events", "occurred_at", descending: [true]);
10599
migrationBuilder.CreateIndex("ix_billing_events_subscription_id", "billing_events", "subscription_id");
100+
101+
// stripe_events extensions for the multi-source reconciliation architecture:
102+
// - api_version: pinned at event creation per https://docs.stripe.com/api/events; lets the
103+
// replayer dispatch to the correct payload resolver when Stripe ships a new API version.
104+
// - payload_hash: SHA-256 of the raw payload at first observation; lets AcknowledgeStripeWebhook
105+
// detect StripeEventPayloadDivergence (same id, different payload) without comparing JSON bodies.
106+
// - recovered_at / recovery_source: non-null when the event was added by reconciliation
107+
// (events.list or webhook_endpoint_deliveries) rather than via webhook delivery — forensic
108+
// marker that a webhook delivery was missed.
109+
migrationBuilder.AddColumn<string>("api_version", "stripe_events", "text", nullable: true);
110+
migrationBuilder.AddColumn<DateTimeOffset>("recovered_at", "stripe_events", "timestamptz", nullable: true);
111+
migrationBuilder.AddColumn<string>("recovery_source", "stripe_events", "text", nullable: true);
112+
migrationBuilder.AddColumn<string>("payload_hash", "stripe_events", "text", nullable: true);
113+
114+
migrationBuilder.CreateIndex("ix_stripe_events_recovered_at", "stripe_events", "recovered_at", filter: "recovered_at IS NOT NULL");
106115
}
107116
}

application/account/Core/Features/Subscriptions/Commands/AcknowledgeStripeWebhook.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using Account.Features.Subscriptions.Domain;
2+
using Account.Features.Subscriptions.Shared;
23
using Account.Integrations.Stripe;
34
using JetBrains.Annotations;
45
using SharedKernel.Cqrs;
6+
using SharedKernel.Telemetry;
57

68
namespace Account.Features.Subscriptions.Commands;
79

@@ -15,7 +17,9 @@ public sealed record AcknowledgeStripeWebhookCommand(string Payload, string Sign
1517
public sealed class AcknowledgeStripeWebhookHandler(
1618
IStripeEventRepository stripeEventRepository,
1719
StripeClientFactory stripeClientFactory,
18-
TimeProvider timeProvider
20+
ITelemetryEventsCollector events,
21+
TimeProvider timeProvider,
22+
ILogger<AcknowledgeStripeWebhookHandler> logger
1923
) : IRequestHandler<AcknowledgeStripeWebhookCommand, Result<StripeCustomerId?>>
2024
{
2125
public async Task<Result<StripeCustomerId?>> Handle(AcknowledgeStripeWebhookCommand command, CancellationToken cancellationToken)
@@ -27,15 +31,31 @@ TimeProvider timeProvider
2731
return Result<StripeCustomerId?>.BadRequest("Invalid webhook signature.");
2832
}
2933

30-
if (await stripeEventRepository.ExistsAsync(webhookEvent.EventId, cancellationToken))
34+
var payloadHash = StripeEventPayloadHasher.Hash(command.Payload);
35+
36+
// Idempotency: Stripe redelivers webhooks on transient errors (network, our 5xx, etc.). Same event
37+
// id arriving twice with the same payload is a no-op. Same id with a *different* payload is a
38+
// forensic anomaly: the existing row is preserved unchanged and a divergence telemetry event is
39+
// emitted so the drift banner can surface it. We never overwrite stripe_events rows.
40+
var existing = await stripeEventRepository.GetByIdAsync(StripeEventId.NewId(webhookEvent.EventId), cancellationToken);
41+
if (existing is not null)
3142
{
43+
if (existing.PayloadHash is not null && existing.PayloadHash != payloadHash)
44+
{
45+
logger.LogWarning(
46+
"Stripe event {EventId} arrived twice with different payloads (existing hash {ExistingHash} vs new {NewHash}); existing row preserved",
47+
webhookEvent.EventId, existing.PayloadHash, payloadHash
48+
);
49+
events.CollectEvent(new StripeEventPayloadMismatch(webhookEvent.EventId, webhookEvent.EventType, existing.PayloadHash, payloadHash));
50+
}
51+
3252
return Result<StripeCustomerId?>.Success(webhookEvent.CustomerId);
3353
}
3454

3555
var now = timeProvider.GetUtcNow();
3656
var customerId = webhookEvent.CustomerId;
3757

38-
var stripeEvent = StripeEvent.Create(webhookEvent.EventId, webhookEvent.EventType, customerId, command.Payload);
58+
var stripeEvent = StripeEvent.Create(webhookEvent.EventId, webhookEvent.EventType, customerId, command.Payload, webhookEvent.ApiVersion, payloadHash);
3959

4060
if (customerId is null)
4161
{

application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ public override string ToString()
2626
/// subscription's drift flag for admin review.
2727
/// Idempotent on <see cref="StripeEventId" /> (unique index): redelivered webhooks and re-pulls from
2828
/// the Stripe events API are no-ops.
29+
/// Source of truth: the local <c>stripe_events</c> archive, NOT Stripe's events.list API. Stripe only
30+
/// retains events for 30 days (see https://docs.stripe.com/api/events) — anything older must come from
31+
/// our local archive. The events.list API is used only as a reconciliation source for detecting
32+
/// webhooks that never reached us within the retention window.
33+
/// Hard rule: rows in this table are never deleted, never updated. Schema changes use ALTER TABLE
34+
/// ADD/DROP COLUMN, never DROP/TRUNCATE/DELETE FROM.
2935
/// </summary>
3036
public sealed class BillingEvent : AggregateRoot<BillingEventId>, ITenantScopedEntity
3137
{
@@ -101,6 +107,13 @@ public static BillingEvent Create(
101107
}
102108
}
103109

110+
/// <summary>
111+
/// The type of subscription-relevant Stripe event recorded by the BillingEvent log.
112+
/// IMPORTANT: when adding a new value, also add it to the multi-select on /billing-events
113+
/// (see <c>application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx</c>,
114+
/// constant <c>ALL_EVENT_TYPES</c>). The toolbar is hand-maintained and does not enumerate the
115+
/// enum at runtime — operators won't be able to filter by a new type until that list is updated.
116+
/// </summary>
104117
[PublicAPI]
105118
[JsonConverter(typeof(JsonStringEnumConverter))]
106119
public enum BillingEventType
@@ -116,6 +129,15 @@ public enum BillingEventType
116129
SubscriptionExpired,
117130
SubscriptionImmediatelyCancelled,
118131
SubscriptionSuspended,
132+
133+
/// <summary>
134+
/// Stripe transitioned the subscription's status from active to past_due (a payment failed).
135+
/// Fires alongside <see cref="PaymentFailed" /> from the corresponding invoice.payment_failed event;
136+
/// pairs with <see cref="SubscriptionReactivated" /> when payment recovers and status returns to active.
137+
/// Carries forward CommittedMrr unchanged and AmountDelta=null — the customer is still on the plan,
138+
/// just behind on payment.
139+
/// </summary>
140+
SubscriptionPastDue,
119141
PaymentFailed,
120142
PaymentRecovered,
121143
PaymentRefunded,

0 commit comments

Comments
 (0)