|
4 | 4 | namespace Account.Database.Migrations; |
5 | 5 |
|
6 | 6 | [DbContext(typeof(AccountDbContext))] |
7 | | -[Migration("20260509120000_AddBillingEventsAndDriftDetection")] |
| 7 | +[Migration("20260509180000_AddBillingEventsAndDriftDetection")] |
8 | 8 | public sealed class AddBillingEventsAndDriftDetection : Migration |
9 | 9 | { |
10 | 10 | protected override void Up(MigrationBuilder migrationBuilder) |
11 | 11 | { |
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: "[]"); |
20 | 17 |
|
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"); |
22 | 19 |
|
23 | 20 | // Subscriptions created before this migration have no subscribed_since because the column did not |
24 | 21 | // 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 |
55 | 52 | """ |
56 | 53 | ); |
57 | 54 |
|
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"))')""" |
75 | 59 | ); |
76 | 60 |
|
| 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). |
77 | 71 | migrationBuilder.CreateTable( |
78 | 72 | "billing_events", |
79 | 73 | table => new |
@@ -103,5 +97,20 @@ ADD CONSTRAINT chk_subscriptions_payment_transactions_tax_breakdown |
103 | 97 | migrationBuilder.CreateIndex("ix_billing_events_tenant_id_occurred_at", "billing_events", ["tenant_id", "occurred_at"], descending: [false, true]); |
104 | 98 | migrationBuilder.CreateIndex("ix_billing_events_occurred_at", "billing_events", "occurred_at", descending: [true]); |
105 | 99 | 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"); |
106 | 115 | } |
107 | 116 | } |
0 commit comments