@@ -101,8 +101,9 @@ async function readDatabaseClock(executor: DbOrTx): Promise<number> {
101101 *
102102 * A Sim commit is stamped with the clock under that lock and rewrites every such event. A value
103103 * taken from Stripe passes `observedAt`, the clock read just before Stripe was read, so it orders
104- * by when it was observed (a slower reconcile of an earlier Stripe read cannot outrank a later
105- * one), and rewrites only the events that do not already carry it.
104+ * by when it was observed: it rewrites (value and stamp) only the events last stamped before that
105+ * observation, including ones already holding the value, so a slower reconcile of an earlier
106+ * Stripe read cannot outrank a later one and never overwrites a newer commit.
106107 */
107108async function commitIntent < T extends SyncIntentFields > (
108109 tx : DbOrTx ,
@@ -120,7 +121,7 @@ async function commitIntent<T extends SyncIntentFields>(
120121 eventType ,
121122 subscriptionSubject ( subscriptionId ) ,
122123 committed ,
123- observedAt === undefined ? undefined : fields
124+ observedAt === undefined ? undefined : 'committedAt'
124125 )
125126 return committed
126127}
@@ -358,15 +359,18 @@ async function readSyncIntents(executor: DbOrTx, subscriptionId: string) {
358359}
359360
360361/**
361- * True when the event records a cancellation change made in Stripe, not by Sim's sync. A change
362- * to `cancel_at` counts too: Better Auth's restore clears `cancel_at` when it is set, and Stripe
363- * may then list only `cancel_at` among the previous attributes.
362+ * True when the event records a cancellation change made in Stripe, not by Sim's sync. A
363+ * `cancel_at` that was set or cleared counts too: Better Auth's restore clears `cancel_at` when it
364+ * is set, and Stripe may then list only `cancel_at` among the previous attributes. A `cancel_at`
365+ * that only moved (e.g. a billing-interval switch on a subscription already ending) is not a
366+ * cancellation change.
364367 */
365368function isCancellationChangedInStripe ( event : Stripe . Event ) : boolean {
366369 const previousAttributes = toRecord ( event . data . previous_attributes )
367- if ( ! ( 'cancel_at_period_end' in previousAttributes ) && ! ( 'cancel_at' in previousAttributes ) ) {
368- return false
369- }
370+ const scheduledOrCleared =
371+ 'cancel_at' in previousAttributes &&
372+ ( previousAttributes . cancel_at == null ) !== ( toRecord ( event . data . object ) . cancel_at == null )
373+ if ( ! ( 'cancel_at_period_end' in previousAttributes ) && ! scheduledOrCleared ) return false
370374 const idempotencyKey = event . request ?. idempotency_key
371375 const issuedBySimSync =
372376 idempotencyKey ?. startsWith ( CANCEL_AT_PERIOD_END_SYNC_KEY_PREFIX ) ||
@@ -410,9 +414,10 @@ function cancelAtPeriodEndSource(
410414 * - `cancelAtPeriodEnd`: while a cancel sync is in flight, its committed value wins over
411415 * snapshots and over echoes of Sim's own writes. A change made in Stripe itself (customer
412416 * portal, dashboard, Better Auth's cancel/restore endpoints), recognised by a non-Sim request
413- * changing `cancel_at_period_end` or `cancel_at`, wins and is committed onto every
414- * sync that can still run, unless Sim committed a newer value after Stripe was read. With no
415- * sync in flight Stripe wins, read live so out-of-order delivery cannot regress it.
417+ * changing `cancel_at_period_end` or setting or clearing `cancel_at`, wins and is committed
418+ * onto every sync that can still run, unless Sim committed a newer value after Stripe was
419+ * read. With no sync in flight Stripe wins, read live so out-of-order delivery cannot regress
420+ * it.
416421 * - Precedence across the two systems is arrival order, not wall-clock order: a Stripe-side
417422 * change whose webhook is processed after a Sim commit wins even if the customer made it
418423 * earlier. Stripe's `event.created` is not compared with the database clock, because skew
0 commit comments