|
| 1 | +using Microsoft.EntityFrameworkCore.Infrastructure; |
| 2 | +using Microsoft.EntityFrameworkCore.Migrations; |
| 3 | + |
| 4 | +namespace Account.Database.Migrations; |
| 5 | + |
| 6 | +[DbContext(typeof(AccountDbContext))] |
| 7 | +[Migration("20260508021500_AddBackOfficeBillingTracking")] |
| 8 | +public sealed class AddBackOfficeBillingTracking : Migration |
| 9 | +{ |
| 10 | + protected override void Up(MigrationBuilder migrationBuilder) |
| 11 | + { |
| 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: "[]"); |
| 17 | + |
| 18 | + migrationBuilder.CreateIndex("ix_subscriptions_has_drift_detected", "subscriptions", "has_drift_detected", filter: "has_drift_detected = true"); |
| 19 | + |
| 20 | + // Subscriptions created before this migration have no subscribed_since because the column did not |
| 21 | + // exist when the Basis -> paid transition occurred. Best available proxy for the start of their paid |
| 22 | + // run is the subscription row's created_at timestamp. Only backfill active paid subscriptions |
| 23 | + // (those that have a Stripe subscription id and are not on the free Basis plan). |
| 24 | + migrationBuilder.Sql( |
| 25 | + """ |
| 26 | + UPDATE subscriptions |
| 27 | + SET subscribed_since = created_at |
| 28 | + WHERE subscribed_since IS NULL |
| 29 | + AND stripe_subscription_id IS NOT NULL |
| 30 | + AND plan <> 'Basis'; |
| 31 | + """ |
| 32 | + ); |
| 33 | + |
| 34 | + // PaymentTransaction.AmountExcludingTax and TaxAmount became non-nullable in the C# domain alongside |
| 35 | + // this migration. Existing rows synced from Stripe before that change may have those keys missing or |
| 36 | + // null. Default AmountExcludingTax to the gross Amount and TaxAmount to 0 so the CHECK constraint |
| 37 | + // below passes. The next Stripe sync per tenant overwrites these with the real breakdown. |
| 38 | + migrationBuilder.Sql( |
| 39 | + """ |
| 40 | + UPDATE subscriptions |
| 41 | + SET payment_transactions = ( |
| 42 | + SELECT jsonb_agg( |
| 43 | + e || jsonb_build_object( |
| 44 | + 'AmountExcludingTax', COALESCE((e->>'AmountExcludingTax')::numeric, (e->>'Amount')::numeric, 0), |
| 45 | + 'TaxAmount', COALESCE((e->>'TaxAmount')::numeric, 0) |
| 46 | + ) |
| 47 | + ) |
| 48 | + FROM jsonb_array_elements(payment_transactions) e |
| 49 | + ) |
| 50 | + WHERE jsonb_array_length(payment_transactions) > 0 |
| 51 | + AND jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))'); |
| 52 | + """ |
| 53 | + ); |
| 54 | + |
| 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"))')""" |
| 59 | + ); |
| 60 | + |
| 61 | + migrationBuilder.CreateTable( |
| 62 | + "billing_events", |
| 63 | + table => new |
| 64 | + { |
| 65 | + tenant_id = table.Column<long>("bigint", nullable: false), |
| 66 | + id = table.Column<string>("text", nullable: false), |
| 67 | + subscription_id = table.Column<string>("text", nullable: false), |
| 68 | + created_at = table.Column<DateTimeOffset>("timestamptz", nullable: false), |
| 69 | + modified_at = table.Column<DateTimeOffset>("timestamptz", nullable: true), |
| 70 | + event_type = table.Column<string>("text", nullable: false), |
| 71 | + from_plan = table.Column<string>("text", nullable: true), |
| 72 | + to_plan = table.Column<string>("text", nullable: true), |
| 73 | + previous_amount = table.Column<decimal>("numeric(18,2)", nullable: true), |
| 74 | + new_amount = table.Column<decimal>("numeric(18,2)", nullable: true), |
| 75 | + amount_delta = table.Column<decimal>("numeric(18,2)", nullable: true), |
| 76 | + currency = table.Column<string>("text", nullable: true), |
| 77 | + days_on_previous_plan = table.Column<int>("integer", nullable: true), |
| 78 | + days_until_effective = table.Column<int>("integer", nullable: true), |
| 79 | + days_since_cancelled = table.Column<int>("integer", nullable: true), |
| 80 | + scheduled_for = table.Column<DateTimeOffset>("timestamptz", nullable: true), |
| 81 | + effective_at = table.Column<DateTimeOffset>("timestamptz", nullable: true), |
| 82 | + occurred_at = table.Column<DateTimeOffset>("timestamptz", nullable: false), |
| 83 | + cancellation_reason = table.Column<string>("text", nullable: true), |
| 84 | + suspension_reason = table.Column<string>("text", nullable: true), |
| 85 | + stripe_reference = table.Column<string>("text", nullable: false) |
| 86 | + }, |
| 87 | + constraints: table => { table.PrimaryKey("pk_billing_events", x => x.id); } |
| 88 | + ); |
| 89 | + |
| 90 | + migrationBuilder.CreateIndex("ix_billing_events_tenant_id_occurred_at", "billing_events", ["tenant_id", "occurred_at"], descending: [false, true]); |
| 91 | + migrationBuilder.CreateIndex("ix_billing_events_occurred_at", "billing_events", "occurred_at", descending: [true]); |
| 92 | + migrationBuilder.CreateIndex("ix_billing_events_subscription_id", "billing_events", "subscription_id"); |
| 93 | + } |
| 94 | +} |
0 commit comments