@@ -67,9 +67,30 @@ WITH state AS ( |
| 67 | 67 | WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(org_billing_states.past_due_at, now()) |
| 68 | 68 | ELSE NULL |
| 69 | 69 | END, |
| 70 | | - locked_at = NULL, |
| 71 | | - lock_reason = NULL, |
| 72 | | - grace_until = NULL, |
| 70 | + -- PRO08 D1: never unconditionally NULL the lock columns. |
| 71 | + -- past_due -> preserve any existing lock (MarkPastDue |
| 72 | + -- sets fresh grace_until on the invoice.payment_failed |
| 73 | + -- path; if that hasn't arrived yet, leave NULL). |
| 74 | + -- active / trialing recovering from past_due/unpaid -> clear. |
| 75 | + -- any other transition -> preserve existing values. |
| 76 | + locked_at = CASE |
| 77 | + WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(org_billing_states.locked_at, now()) |
| 78 | + WHEN EXCLUDED.subscription_status IN ('active', 'trialing') |
| 79 | + AND org_billing_states.subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN NULL |
| 80 | + ELSE org_billing_states.locked_at |
| 81 | + END, |
| 82 | + lock_reason = CASE |
| 83 | + WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(org_billing_states.lock_reason, 'past_due'::billing_lock_reason) |
| 84 | + WHEN EXCLUDED.subscription_status IN ('active', 'trialing') |
| 85 | + AND org_billing_states.subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN NULL |
| 86 | + ELSE org_billing_states.lock_reason |
| 87 | + END, |
| 88 | + grace_until = CASE |
| 89 | + WHEN EXCLUDED.subscription_status = 'past_due' THEN org_billing_states.grace_until |
| 90 | + WHEN EXCLUDED.subscription_status IN ('active', 'trialing') |
| 91 | + AND org_billing_states.subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN NULL |
| 92 | + ELSE org_billing_states.grace_until |
| 93 | + END, |
| 73 | 94 | updated_at = now() |
| 74 | 95 | RETURNING org_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, billable_seats, seat_snapshot_at, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at |
| 75 | 96 | ), org_update AS ( |
@@ -217,9 +238,27 @@ WITH state AS ( |
| 217 | 238 | WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(user_billing_states.past_due_at, now()) |
| 218 | 239 | ELSE NULL |
| 219 | 240 | END, |
| 220 | | - locked_at = NULL, |
| 221 | | - lock_reason = NULL, |
| 222 | | - grace_until = NULL, |
| 241 | + -- PRO08 D1: never unconditionally NULL the lock columns |
| 242 | + -- (mirror of the org-side fix). The Mark* paths own |
| 243 | + -- transitions into/out of the locked state. |
| 244 | + locked_at = CASE |
| 245 | + WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(user_billing_states.locked_at, now()) |
| 246 | + WHEN EXCLUDED.subscription_status IN ('active', 'trialing') |
| 247 | + AND user_billing_states.subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN NULL |
| 248 | + ELSE user_billing_states.locked_at |
| 249 | + END, |
| 250 | + lock_reason = CASE |
| 251 | + WHEN EXCLUDED.subscription_status = 'past_due' THEN COALESCE(user_billing_states.lock_reason, 'past_due'::billing_lock_reason) |
| 252 | + WHEN EXCLUDED.subscription_status IN ('active', 'trialing') |
| 253 | + AND user_billing_states.subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN NULL |
| 254 | + ELSE user_billing_states.lock_reason |
| 255 | + END, |
| 256 | + grace_until = CASE |
| 257 | + WHEN EXCLUDED.subscription_status = 'past_due' THEN user_billing_states.grace_until |
| 258 | + WHEN EXCLUDED.subscription_status IN ('active', 'trialing') |
| 259 | + AND user_billing_states.subscription_status IN ('past_due', 'unpaid', 'incomplete') THEN NULL |
| 260 | + ELSE user_billing_states.grace_until |
| 261 | + END, |
| 223 | 262 | updated_at = now() |
| 224 | 263 | RETURNING user_id, provider, stripe_customer_id, stripe_subscription_id, stripe_subscription_item_id, plan, subscription_status, current_period_start, current_period_end, cancel_at_period_end, trial_end, past_due_at, canceled_at, locked_at, lock_reason, grace_until, last_webhook_event_id, created_at, updated_at |
| 225 | 264 | ), user_update AS ( |