Skip to content

chore(deps): upgrade dj-stripe to 2.10 DEV-1918#6863

Open
noliveleger wants to merge 20 commits into
release/2.026.13from
django-5.2-update-stripe
Open

chore(deps): upgrade dj-stripe to 2.10 DEV-1918#6863
noliveleger wants to merge 20 commits into
release/2.026.13from
django-5.2-update-stripe

Conversation

@noliveleger

@noliveleger noliveleger commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

💭 Notes

Many breaking changes in 2.10. Please read https://github.com/dj-stripe/dj-stripe/releases/tag/2.10.0

dj-stripe 2.10 consolidated nearly all Stripe model fields into a single stripe_data JSONField. Direct DB column access is replaced by @property accessors reading from stripe_data, so attribute access on model instances still works, but ORM .filter() / .values() / F() calls must use stripe_data__<field> JSON lookups.

ORM filter/annotation changes:

  • All status, start_date, billing_cycle_anchor, current_period_start, current_period_end, ended_at, unit_amount, recurring, refunded, quantity lookups now go through stripe_data__<field>
  • Max('prices__unit_amount') replaced with Max(Cast(KeyTransform('unit_amount', 'prices__stripe_data'), output_field=IntegerField()))
  • F('start_date') replaced with Cast(F('stripe_data__start_date'), output_field=BigIntegerField()) since timestamps are now Unix ints
  • charge.payment_intent.statuscharge.payment_intent.stripe_data.get('status')
  • charge.refundedcharge.stripe_data.get('refunded')
  • subscription.plan.product no longer works; replaced with iteration via subscription.items.select_related('price__product')
  • .select_related('schedule') removed (field moved to stripe_data)

Raw SQL (materialized view):

  • user_reports/utils/migrations.py: all column references rewritten to stripe_data->>'field' or stripe_data->'field' JSON operators
  • default_tax_rates subquery joining djstripe_djstripesubscriptiondefaulttaxrate M2M table replaced with sub.stripe_data->'default_tax_rates' (M2M table dropped in 2.10)
  • Old migration 0006 emptied; new 0007_djstripe_update recreates the materialized view with updated SQL

Migration strategy:

  • stripe.0001_initial dependency changed from ('djstripe', '0011_2_7')('djstripe', '0001_initial') (exists in all djstripe versions)
  • New empty stripe.0005_djstripe_2_10 depends on ('djstripe', '0002_2_10') as a bridge migration — this avoids InconsistentMigrationHistory on upgrade since djstripe 2.10 reset migrations without Django's replaces mechanism

Test fixtures:

  • All baker.make() calls updated: fields like status, unit_amount, recurring, billing_cycle_anchor, etc. are now @property with no setter — values must be passed via stripe_data={...} with Unix timestamps
  • Datetime comparisons truncated to replace(microsecond=0) since Unix timestamps lose microsecond precision

@noliveleger noliveleger requested a review from jnm as a code owner March 24, 2026 15:39
@noliveleger noliveleger self-assigned this Mar 24, 2026
@coderabbitai

coderabbitai Bot commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request migrates Stripe-related reads to use JSON payloads stored in stripe_data across the codebase (ORM filters, annotations, utilities, serializers, tests, and materialized-view SQL), updates many Python dependency pins, simplifies OpenAPI/TypeScript models by removing several enums and narrowing subscription schema, adds and adjusts Django migrations (including a squashed user_reports migration and migration ordering bridge for djstripe), and updates CI workflows to Python 3.12. Test fixtures and factories were updated to populate stripe_data instead of top-level Stripe convenience fields.

Sequence Diagram

sequenceDiagram
    participant Client as Client/API
    participant App as Application Code
    participant ORM as Django ORM
    participant DB as Database (JSONB)
    participant djstripe as djstripe Models

    Client->>App: Request billing/subscription data
    App->>ORM: Query subscriptions (filter stripe_data__status__in=...)
    ORM->>djstripe: Resolve Subscription model/query
    djstripe->>DB: Read subscription.stripe_data JSONB fields
    DB-->>djstripe: Return JSON values (status, period timestamps, unit_amount)
    djstripe-->>ORM: Provide annotated fields (casted)
    ORM-->>App: Queryset/results with extracted values
    App->>App: Convert epoch ints -> timezone-aware datetimes
    App-->>Client: Respond with billing/subscription info
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title follows the required format with type, scope, and ticket ID, clearly describing the main change of upgrading dj-stripe to version 2.10.
Description check ✅ Passed The description is directly related to the changeset, providing detailed context about breaking changes, ORM modifications, migration strategy, and test fixture updates required for dj-stripe 2.10 compatibility.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@noliveleger noliveleger requested review from Copilot and jamesrkiger and removed request for jnm March 24, 2026 15:40
@noliveleger noliveleger changed the title chore(deps): upgrade dj-stripe to 2.10 chore(deps): upgrade dj-stripe to 2.10 DEV-1918 Mar 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR upgrades dj-stripe to the 2.10.x series and updates the codebase to accommodate dj-stripe’s shift of many model fields into stripe_data JSON, including ORM lookups/annotations, raw SQL for the user reports materialized view, and test fixture creation patterns.

Changes:

  • Upgrade dj-stripe to 2.10.3 and adjust dependency inputs/lockfiles accordingly.
  • Refactor ORM queries/annotations to use stripe_data__… JSON lookups and adapt code paths that relied on removed/changed relations (e.g., schedule, plan/product traversal).
  • Update user reports materialized view SQL + add migrations to safely rebuild it under the new dj-stripe schema; update tests/fixtures to write via stripe_data and handle Unix timestamps.

Reviewed changes

Copilot reviewed 30 out of 30 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
kobo/apps/user_reports/utils/migrations.py Updates MV SQL to read subscription/price/product fields from stripe_data JSON.
kobo/apps/user_reports/tests/test_user_reports.py Adjusts bakery fixtures to set dj-stripe fields via stripe_data.
kobo/apps/user_reports/migrations/0007_djstripe_update.py Adds migration to rebuild the MV/indexes for dj-stripe 2.10 schema.
kobo/apps/user_reports/migrations/0006_fix_org_subscriptions_missing_metadata.py Converts prior heavy migration into a no-op placeholder in the chain.
kobo/apps/user_reports/migrations/0005_fix_infinite_usage_and_last_updated.py Removes flake8 header comment.
kobo/apps/user_reports/migrations/0004_fix_social_accounts_aggregation.py Removes flake8 header comment.
kobo/apps/user_reports/migrations/0003_fix_user_role_scopping.py Removes flake8 header comment.
kobo/apps/user_reports/migrations/0002_create_user_reports_mv.py Removes flake8 header comment.
kobo/apps/trackers/tests/test_utils.py Updates dj-stripe model creation to use stripe_data.
kobo/apps/stripe/views.py Updates subscription filtering + replaces price max aggregation with JSON extraction + cast.
kobo/apps/stripe/utils/subscription_limits.py Updates active subscription filtering and replaces plan traversal with item/price/product traversal; casts JSON timestamps.
kobo/apps/stripe/utils/billing_dates.py Updates org billing-period queries to filter/calculate based on stripe_data timestamps.
kobo/apps/stripe/tests/utils.py Updates shared test helpers/fixtures to populate dj-stripe models via stripe_data and timestamps.
kobo/apps/stripe/tests/test_subscription_api.py Updates fixtures to set billing_scheme via stripe_data.
kobo/apps/stripe/tests/test_stripe_utils.py Updates tests for timestamp precision/type changes + item/product access changes.
kobo/apps/stripe/tests/test_product_api.py Updates price fixtures to set billing_scheme via stripe_data.
kobo/apps/stripe/tests/test_organization_usage.py Updates tests to compare ISO strings derived from Unix timestamps in dj-stripe properties.
kobo/apps/stripe/tests/test_one_time_addons_api.py Updates subscription fixture status to be stored in stripe_data.
kobo/apps/stripe/tests/test_modify_subscription_api.py Updates fixtures to set price/subscription item/subscription fields via stripe_data.
kobo/apps/stripe/tests/test_mfa_login.py Updates fixtures to attach subscription items correctly and store status/unit_amount in stripe_data.
kobo/apps/stripe/tests/test_customer_portal_api.py Adjusts subscription creation to work with new subscription-item patterns and stripe_data status.
kobo/apps/stripe/serializers.py Updates queryset filter to use stripe_data__status.
kobo/apps/stripe/models.py Updates runtime logic and queries referencing moved fields (status/refunded) to stripe_data.
kobo/apps/stripe/migrations/0005_djstripe_2_10.py Adds a bridge migration depending on djstripe.0002_2_10 to avoid migration history issues.
kobo/apps/stripe/migrations/0001_initial.py Updates djstripe dependency to 0001_initial for broader compatibility.
kobo/apps/organizations/models.py Updates subscription-related queries to use stripe_data__… JSON lookups.
kobo/apps/accounts/utils.py Updates paid-subscription checks to use stripe_data unit_amount/status lookups.
dependencies/pip/requirements.txt Pins dj-stripe==2.10.3 and updates compiled dependency set.
dependencies/pip/requirements.in Loosens input constraint to dj-stripe>=2.10.
dependencies/pip/dev_requirements.txt Pins dev dependency set to include dj-stripe==2.10.3.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +121 to +126
anchor=Max(
Cast(
F('djstripe_customers__subscriptions__stripe_data__ended_at'),
output_field=BigIntegerField(),
)
),

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These annotations cast JSON subkeys (e.g., stripe_data__ended_at) to BigIntegerField. If the underlying JSON key transform resolves to a JSONB value (Postgres ->), casting JSONB directly to bigint will fail. Prefer extracting as text first (e.g., KeyTextTransform('ended_at', ...) / ->>), then casting, so Max() and comparisons work reliably.

Copilot uses AI. Check for mistakes.
Comment on lines +266 to +269
sub_start_date=Cast(
F('stripe_data__start_date'),
output_field=BigIntegerField(),
),

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sub_start_date is derived from stripe_data__start_date and cast to BigIntegerField(). If the JSON key transform yields JSONB (Postgres ->), the cast will fail at runtime. Extract the JSON key as text first (e.g., KeyTextTransform / ->>) and then cast, to guarantee numeric ordering in the window functions.

Copilot uses AI. Check for mistakes.
Comment on lines +314 to +318
unique_products = set()
for subscription in subscriptions:
unique_plans.add(subscription.plan)
for item in subscription.items.select_related('price__product').all():
if item.price and item.price.product:
unique_products.add(item.price.product)

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_plan_name() now loops through subscriptions and, inside that loop, runs subscription.items.select_related(...).all(). This will issue an extra query per subscription (N+1) and can become expensive for orgs with many subscriptions. Consider prefetching items (with price__product) on the initial subscriptions queryset and then iterating subscription.items.all() without per-subscription queries.

Copilot uses AI. Check for mistakes.
Comment thread kobo/apps/stripe/views.py
Comment on lines +617 to +623
.annotate(
highest_unit_amount=Max(
Cast(
KeyTransform('unit_amount', 'prices__stripe_data'),
output_field=IntegerField(),
)
)

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeyTransform('unit_amount', 'prices__stripe_data') returns a JSON/JSONB expression; casting that directly to IntegerField() is likely to error on Postgres (cannot cast type jsonb to integer). Use a text transform (e.g., KeyTextTransform) or extract the key as text (->>) before casting so Max() can operate on a numeric type.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
kobo/apps/stripe/models.py (1)

86-94: ⚠️ Potential issue | 🟠 Major

Use has_any_keys here and filter empty tags.

Blank valid_tags becomes [''] with split(','), making the condition at line 88 truthy even when no tags are specified. Additionally, __has_key__in is not a standard Django JSONField lookup; the correct lookup is has_any_keys. This combination causes untagged add-ons to be incorrectly treated as tagged, and can reject organizations that should be eligible to purchase the add-on.

Suggested fix
-        tags = product.metadata.get('valid_tags', '').split(',')
+        tags = [
+            tag for tag in product.metadata.get('valid_tags', '').split(',')
+            if tag
+        ]
         if (
             tags
             and ('all' not in tags)
             and not Subscription.objects.filter(
                 customer__subscriber=organization,
-                items__price__product__metadata__has_key__in=[tags],
+                items__price__product__metadata__has_any_keys=tags,
                 stripe_data__status__in=ACTIVE_STRIPE_STATUSES,
             ).exists()
         ):
kobo/apps/organizations/models.py (1)

116-145: ⚠️ Potential issue | 🟠 Major

Make the selected billing row deterministic.

This queryset now expands one subscription into one row per subscription item, but .first() is still only ordered by subscriptions__stripe_data__start_date. If the newest subscription has multiple items, the returned price_id and metadata depend on join order instead of a defined contract.

🧹 Nitpick comments (5)
dependencies/pip/requirements.in (1)

30-30: Consider adding an upper bound constraint.

The >=2.10 constraint allows any future dj-stripe version, including potential major releases (e.g., 3.x) that could introduce breaking changes. Consider constraining to >=2.10,<3 to prevent unexpected upgrade issues while still allowing patch/minor updates.

Suggested constraint
-dj-stripe>=2.10
+dj-stripe>=2.10,<3
kobo/apps/accounts/utils.py (1)

11-19: Plan indexes for these JSON-backed auth checks.

These helpers now run through stripe_data keys on the login/MFA path. Django documents that the default B-tree index isn't particularly helpful for JSONField queries and that PostgreSQL should use JSON-appropriate indexing instead, so I'd plan dedicated indexes around these lookups if auth latency moves after this upgrade. (docs.djangoproject.com)

Also applies to: 27-31

kobo/apps/stripe/utils/subscription_limits.py (2)

248-269: Add expression indexes for the new JSON-backed lookups.

status filtering and start_date ranking used to ride normal columns; now this request path has to read and cast values out of stripe_data before the window functions run. If djstripe_subscription does not already have matching expression indexes (for example on ((stripe_data->>'status')) and ((stripe_data->>'start_date')::bigint)), this will get noticeably slower as the table grows.


314-326: Prefetch subscription items and stabilize plan-name ordering.

This loop does one items query per subscription, and the set plus boolean-only sort makes the final "A and B" string unstable when there are multiple products of the same type. Prefetch once and sort with a deterministic secondary key.

♻️ Possible refactor
-    unique_products = set()
+    subscriptions = subscriptions.prefetch_related('items__price__product')
+    unique_products = {}
     for subscription in subscriptions:
-        for item in subscription.items.select_related('price__product').all():
-            if item.price and item.price.product:
-                unique_products.add(item.price.product)
+        for item in subscription.items.all():
+            product = getattr(getattr(item, 'price', None), 'product', None)
+            if product:
+                unique_products[product.pk] = product

     # Make sure plans come before addons
     product_list = sorted(
-        unique_products,
-        key=lambda product: product.metadata.get('product_type', '') == 'plan',
-        reverse=True,
+        unique_products.values(),
+        key=lambda product: (
+            product.metadata.get('product_type') != 'plan',
+            product.name,
+        ),
     )
kobo/apps/stripe/tests/utils.py (1)

94-115: Prefer building the SubscriptionItem lazily.

baker.make() writes the SubscriptionItem before the intended Subscription exists, so this helper either does an extra write or depends on model_bakery/djstripe FK defaults while the relation is still unset. baker.prepare() here would avoid that intermediate state.

♻️ Suggested cleanup
-    subscription_item = baker.make(
-        SubscriptionItem,
-        price=price,
-        livemode=False,
-        plan=plan,
-        stripe_data={'quantity': 1},
-    )
     subscription = baker.make(
         Subscription,
         customer=customer,
         livemode=False,
         metadata=subscription_metadata,
         stripe_data={
             'status': status,
             'billing_cycle_anchor': int((created_date - period_offset).timestamp()),
             'current_period_end': int((created_date + period_offset).timestamp()),
             'current_period_start': int((created_date - period_offset).timestamp()),
             'start_date': int(created_date.timestamp()),
         },
     )
-    subscription_item.subscription = subscription
+    subscription_item = baker.prepare(
+        SubscriptionItem,
+        subscription=subscription,
+        price=price,
+        livemode=False,
+        plan=plan,
+        stripe_data={'quantity': 1},
+    )
     subscription_item.save()

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ecea96e0-1e1a-4f72-9375-f1fb62ca5b1e

📥 Commits

Reviewing files that changed from the base of the PR and between 265169a and f94fbd3.

📒 Files selected for processing (30)
  • dependencies/pip/dev_requirements.txt
  • dependencies/pip/requirements.in
  • dependencies/pip/requirements.txt
  • kobo/apps/accounts/utils.py
  • kobo/apps/organizations/models.py
  • kobo/apps/stripe/migrations/0001_initial.py
  • kobo/apps/stripe/migrations/0005_djstripe_2_10.py
  • kobo/apps/stripe/models.py
  • kobo/apps/stripe/serializers.py
  • kobo/apps/stripe/tests/test_customer_portal_api.py
  • kobo/apps/stripe/tests/test_mfa_login.py
  • kobo/apps/stripe/tests/test_modify_subscription_api.py
  • kobo/apps/stripe/tests/test_one_time_addons_api.py
  • kobo/apps/stripe/tests/test_organization_usage.py
  • kobo/apps/stripe/tests/test_product_api.py
  • kobo/apps/stripe/tests/test_stripe_utils.py
  • kobo/apps/stripe/tests/test_subscription_api.py
  • kobo/apps/stripe/tests/utils.py
  • kobo/apps/stripe/utils/billing_dates.py
  • kobo/apps/stripe/utils/subscription_limits.py
  • kobo/apps/stripe/views.py
  • kobo/apps/trackers/tests/test_utils.py
  • kobo/apps/user_reports/migrations/0002_create_user_reports_mv.py
  • kobo/apps/user_reports/migrations/0003_fix_user_role_scopping.py
  • kobo/apps/user_reports/migrations/0004_fix_social_accounts_aggregation.py
  • kobo/apps/user_reports/migrations/0005_fix_infinite_usage_and_last_updated.py
  • kobo/apps/user_reports/migrations/0006_fix_org_subscriptions_missing_metadata.py
  • kobo/apps/user_reports/migrations/0007_djstripe_update.py
  • kobo/apps/user_reports/tests/test_user_reports.py
  • kobo/apps/user_reports/utils/migrations.py
💤 Files with no reviewable changes (4)
  • kobo/apps/user_reports/migrations/0002_create_user_reports_mv.py
  • kobo/apps/user_reports/migrations/0005_fix_infinite_usage_and_last_updated.py
  • kobo/apps/user_reports/migrations/0004_fix_social_accounts_aggregation.py
  • kobo/apps/user_reports/migrations/0003_fix_user_role_scopping.py

Comment thread kobo/apps/stripe/tests/utils.py
Comment thread kobo/apps/stripe/tests/utils.py
Comment thread kobo/apps/stripe/tests/utils.py
Comment thread kobo/apps/user_reports/migrations/0007_djstripe_update.py Outdated
@greptile-apps

greptile-apps Bot commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR upgrades dj-stripe from 2.7 to 2.10, which consolidated nearly all model fields into a single stripe_data JSONField. The change is broad (55 files) and systematically rewrites all ORM filters, materialized-view SQL, serializer fields, and test fixtures to use the new JSON lookup pattern.

Previous review threads have already surfaced the main open concerns: inconsistent refunded handling between ORM and instance-level checks, canceled_subscription_billing_cycle_anchor returning a raw Unix integer instead of a datetime, timestamp format changes in the materialized view, and the schedule FK being silently dropped from the subscription serializer. These threads should be resolved or explicitly deferred before merge.

Confidence Score: 4/5

PR is broadly safe but prior review threads flagging the schedule serializer null-return and MV timestamp-format change should be resolved or explicitly deferred before merge.

The migration and ORM rewrites are systematic and well-justified. However, several P1 concerns surfaced in earlier review rounds remain open: the schedule FK silently dropped from the subscription serializer, the MV emitting raw Unix integers for timestamp fields previously typed as ISO datetimes, and canceled_subscription_billing_cycle_anchor returning a raw integer instead of a datetime.

kobo/apps/stripe/serializers.py (schedule field), kobo/apps/user_reports/utils/migrations.py (timestamp field format in MV), kobo/apps/organizations/models.py (canceled_subscription_billing_cycle_anchor return type)

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Fresh install] --> B[stripe.0001_initial depends on djstripe.0001_initial]
    A --> C[user_reports.0001_squashed_old_migrations replaces 0001-0007]
    C --> D[user_reports.0002_reset_mv_for_djstripe_update DROP + recreate MV]
    E[Existing install pre-2.10] --> F[Run djstripe.0002_2_10 single migration]
    E --> G[stripe.0005_djstripe_2_10 bridge migration]
    E --> H[user_reports.0008_drop_mv_before_djstripe DROP MV]
    H --> D
    F --> D
    D --> I{SKIP_HEAVY_MIGRATIONS?}
    I -- No --> J[Execute CREATE_MV_SQL with stripe_data JSON operators]
    I -- Yes --> K[Reset LRM 0019 to reschedule async MV rebuild]
Loading

Reviews (16): Last reviewed commit: "fix merge conflicts" | Re-trigger Greptile

Comment thread kobo/apps/stripe/models.py
Comment thread kobo/apps/stripe/serializers.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
static/openapi/schema_v2.yaml (1)

17945-17952: ⚠️ Potential issue | 🟠 Major

Model subscription item quantity as nullable.

Stripe's subscription item schema marks quantity as nullable, and the same docs note that metered prices aggregate usage instead of billing a fixed quantity. Keeping this as a required non-null integer means valid metered items won't be represented correctly in the OpenAPI contract.

Suggested fix
        quantity:
          type: integer
+         nullable: true
♻️ Duplicate comments (1)
kobo/apps/user_reports/migrations/0007_djstripe_update.py (1)

5-9: ⚠️ Potential issue | 🟠 Major

Freeze SQL inside this migration to keep it reproducible.

Line 5-Line 9 and Line 31-Line 34 make this migration depend on whichever SQL the helper module contains at runtime. That breaks migration determinism on fresh installs/upgrades.

Suggested fix
-from kobo.apps.user_reports.utils.migrations import (
-    CREATE_INDEXES_SQL,
-    CREATE_MV_SQL,
-    DROP_MV_SQL,
-)
+# Snapshot the SQL at migration creation time to keep this migration immutable.
+DROP_MV_SQL = """
+-- paste exact DROP SQL snapshot here
+"""
+
+CREATE_MV_SQL = """
+-- paste exact CREATE MATERIALIZED VIEW SQL snapshot here
+"""
+
+CREATE_INDEXES_SQL = """
+-- paste exact CREATE INDEX SQL snapshot here
+"""
#!/bin/bash
# Verify this migration imports live helper SQL and therefore is not self-contained.

rg -n "from kobo.apps.user_reports.utils.migrations import|CREATE_MV_SQL|DROP_MV_SQL|CREATE_INDEXES_SQL" \
  kobo/apps/user_reports/migrations/0007_djstripe_update.py

# Show helper SQL definitions currently referenced at runtime.
fd migrations.py kobo/apps/user_reports/utils --exec sed -n '1,260p' {}

# Check whether other migrations have the same pattern.
rg -n "from kobo.apps.user_reports.utils.migrations import" kobo/apps/user_reports/migrations

Also applies to: 31-34

🧹 Nitpick comments (2)
kobo/apps/organizations/models.py (1)

157-169: Verify ended_at timestamp format is handled correctly downstream.

Similar to other timestamp fields, ended_at will now be a Unix timestamp (integer) from stripe_data. The return value at Line 169 (qs['anchor']) will be an integer, not a datetime. Confirm this is the expected behavior for callers of canceled_subscription_billing_cycle_anchor().

static/openapi/schema_v2.json (1)

17521-17523: Keep the enum contract for Stripe type fields if the value set has not changed.

Collapsing these to string | null drops generated enums and client-side validation without changing the wire format. If the backend still only returns Stripe’s fixed values here, an explicit ChoiceField or schema override would keep the OpenAPI contract precise.

Also applies to: 23429-23431, 23494-23496, 23557-23560


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2eef314e-53ec-4952-964e-37f8c318453c

📥 Commits

Reviewing files that changed from the base of the PR and between f94fbd3 and b5d1b76.

📒 Files selected for processing (36)
  • .github/workflows/pytest.yml
  • dependencies/pip/requirements.in
  • jsapp/js/api/models/baseProduct.ts
  • jsapp/js/api/models/blankEnum.ts
  • jsapp/js/api/models/collectionMethodEnum.ts
  • jsapp/js/api/models/price.ts
  • jsapp/js/api/models/priceTransformQuantity.ts
  • jsapp/js/api/models/priceWithProduct.ts
  • jsapp/js/api/models/priceWithProductTransformQuantity.ts
  • jsapp/js/api/models/product.ts
  • jsapp/js/api/models/prorationBehaviorEnum.ts
  • jsapp/js/api/models/stripePriceType.ts
  • jsapp/js/api/models/stripeProductType.ts
  • jsapp/js/api/models/subscription.ts
  • jsapp/js/api/models/subscriptionBillingThresholds.ts
  • jsapp/js/api/models/subscriptionDiscount.ts
  • jsapp/js/api/models/subscriptionItem.ts
  • jsapp/js/api/models/subscriptionPauseCollection.ts
  • jsapp/js/api/models/subscriptionPendingInvoiceItemInterval.ts
  • jsapp/js/api/models/subscriptionPendingUpdate.ts
  • jsapp/js/api/models/subscriptionProrationBehavior.ts
  • jsapp/js/api/models/subscriptionStatusEnum.ts
  • kobo/apps/organizations/models.py
  • kobo/apps/stripe/migrations/0001_initial.py
  • kobo/apps/stripe/models.py
  • kobo/apps/stripe/serializers.py
  • kobo/apps/stripe/tests/test_customer_portal_api.py
  • kobo/apps/stripe/tests/test_modify_subscription_api.py
  • kobo/apps/stripe/tests/test_stripe_utils.py
  • kobo/apps/stripe/utils/billing_dates.py
  • kobo/apps/stripe/views.py
  • kobo/apps/trackers/tests/test_utils.py
  • kobo/apps/user_reports/migrations/0007_djstripe_update.py
  • kobo/apps/user_reports/utils/migrations.py
  • static/openapi/schema_v2.json
  • static/openapi/schema_v2.yaml
💤 Files with no reviewable changes (15)
  • jsapp/js/api/models/priceTransformQuantity.ts
  • jsapp/js/api/models/priceWithProductTransformQuantity.ts
  • jsapp/js/api/models/subscriptionPauseCollection.ts
  • jsapp/js/api/models/subscriptionPendingInvoiceItemInterval.ts
  • jsapp/js/api/models/blankEnum.ts
  • jsapp/js/api/models/subscriptionProrationBehavior.ts
  • jsapp/js/api/models/subscriptionPendingUpdate.ts
  • jsapp/js/api/models/stripePriceType.ts
  • jsapp/js/api/models/prorationBehaviorEnum.ts
  • jsapp/js/api/models/subscriptionBillingThresholds.ts
  • jsapp/js/api/models/subscriptionDiscount.ts
  • jsapp/js/api/models/stripeProductType.ts
  • jsapp/js/api/models/subscriptionStatusEnum.ts
  • jsapp/js/api/models/collectionMethodEnum.ts
  • jsapp/js/api/models/subscription.ts
✅ Files skipped from review due to trivial changes (1)
  • kobo/apps/stripe/tests/test_customer_portal_api.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • kobo/apps/stripe/tests/test_modify_subscription_api.py
  • kobo/apps/stripe/tests/test_stripe_utils.py
  • kobo/apps/stripe/views.py
  • kobo/apps/stripe/migrations/0001_initial.py
  • kobo/apps/stripe/utils/billing_dates.py
  • kobo/apps/trackers/tests/test_utils.py
  • dependencies/pip/requirements.in

Comment on lines +39 to +43
dependencies = [('user_reports', '0006_fix_org_subscriptions_missing_metadata')]

operations = [
migrations.RunPython(apply_fix, reverse_code=migrations.RunPython.noop),
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify RUF012 applicability and local patterns.

for f in pyproject.toml .ruff.toml ruff.toml; do
  [ -f "$f" ] && echo "== $f ==" && rg -n "RUF012|select|extend-select|ignore|extend-ignore" "$f";
done

rg -n "dependencies = \\[|operations = \\[" \
  kobo/apps/user_reports/migrations/0007_djstripe_update.py

Repository: kobotoolbox/kpi

Length of output: 600


🏁 Script executed:

cat -n ruff.toml

Repository: kobotoolbox/kpi

Length of output: 1360


🏁 Script executed:

cat -n kobo/apps/user_reports/migrations/0007_djstripe_update.py

Repository: kobotoolbox/kpi

Length of output: 1547


Use tuples for dependencies and operations to satisfy RUF012.

Ruff flags these class attributes as mutable defaults. Converting lists to tuples removes the warning with no behavior change.

Suggested fix
-    dependencies = [('user_reports', '0006_fix_org_subscriptions_missing_metadata')]
+    dependencies = (('user_reports', '0006_fix_org_subscriptions_missing_metadata'),)

-    operations = [
-        migrations.RunPython(apply_fix, reverse_code=migrations.RunPython.noop),
-    ]
+    operations = (
+        migrations.RunPython(apply_fix, reverse_code=migrations.RunPython.noop),
+    )
🧰 Tools
🪛 Ruff (0.15.6)

[warning] 39-39: Mutable default value for class attribute

(RUF012)


[warning] 41-43: Mutable default value for class attribute

(RUF012)

Comment on lines 23461 to 23463
"transform_quantity": {
"nullable": true,
"description": "Apply a transformation to the reported usage or set quantity before computing the amount billed. Cannot be combined with `tiers`."
"nullable": true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the file size and structure
wc -l static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 94


🏁 Script executed:

# Read the specific lines mentioned in the review
sed -n '23459,23465p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 321


🏁 Script executed:

# Also check the second location mentioned (23525-23527)
sed -n '23523,23529p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 304


🏁 Script executed:

# Check if there's git history to see what was removed
cd static/openapi && git log -1 --oneline -- schema_v2.json 2>/dev/null || echo "No git history available"

Repository: kobotoolbox/kpi

Length of output: 156


🏁 Script executed:

# Look for any other definitions of transform_quantity in the file to understand the schema structure
rg "transform_quantity" static/openapi/schema_v2.json -B 2 -A 5

Repository: kobotoolbox/kpi

Length of output: 3959


🏁 Script executed:

# Search for the complete transform_quantity schema definition
rg "transform_quantity" static/openapi/schema_v2.json -B 1 -A 8 | head -80

Repository: kobotoolbox/kpi

Length of output: 4461


🏁 Script executed:

# Look for where the object schema with properties is defined
rg '"divide_by"' static/openapi/schema_v2.json -B 10 -A 3

Repository: kobotoolbox/kpi

Length of output: 1121


🏁 Script executed:

# Check if there's a reusable schema component defined for transform_quantity
rg -A 15 '"TransformQuantity"' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 41


🏁 Script executed:

# Look at the complete context where the full transform_quantity schema is defined
sed -n '20000,20100p' static/openapi/schema_v2.json | rg -A 12 "transform_quantity"

Repository: kobotoolbox/kpi

Length of output: 41


🏁 Script executed:

# Get a better view of the schema structure around the full definition
ast-grep --pattern 'transform_quantity: {
  type: "object",
  $$$
}'

Repository: kobotoolbox/kpi

Length of output: 41


🏁 Script executed:

# Find all line numbers where transform_quantity appears
rg -n "transform_quantity" static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 2355


🏁 Script executed:

# Check the line numbers to understand the structure better
rg -n "transform_quantity" static/openapi/schema_v2.json | head -20

Repository: kobotoolbox/kpi

Length of output: 2355


🏁 Script executed:

# Check the full schema definition starting at line 26398
sed -n '26395,26420p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 1671


🏁 Script executed:

# Let's also see more context around this to understand the schema structure
sed -n '26390,26430p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 2734


🏁 Script executed:

# Check if there's a components/schemas section with a TransformQuantity definition
rg -n "components.*schemas" static/openapi/schema_v2.json | head -5

Repository: kobotoolbox/kpi

Length of output: 519


🏁 Script executed:

# Let's look for any schema definitions that could be reused
sed -n '1,100p' static/openapi/schema_v2.json | grep -i schema

Repository: kobotoolbox/kpi

Length of output: 476


🏁 Script executed:

# Check the structure of the file to find components section
rg -n '"components"' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 84


🏁 Script executed:

# Check the components section to see if there's a schema definition for TransformQuantity
sed -n '15941,16000p' static/openapi/schema_v2.json | head -50

Repository: kobotoolbox/kpi

Length of output: 1901


🏁 Script executed:

# Look for all schema definitions that might contain transform_quantity as a reusable component
sed -n '15941,27012p' static/openapi/schema_v2.json | rg -B 5 -A 15 '"TransformQuantity"'

Repository: kobotoolbox/kpi

Length of output: 41


🏁 Script executed:

# Check what schemas are defined in the components section
sed -n '15941,20000p' static/openapi/schema_v2.json | rg '"([A-Z][a-zA-Z]+)":\s*\{' | head -30

Repository: kobotoolbox/kpi

Length of output: 1205


🏁 Script executed:

# Get a list of all schemas to see if TransformQuantity exists
sed -n '15941,27012p' static/openapi/schema_v2.json | rg '^\s*"[A-Z][a-zA-Z]+":\s*\{' | grep -i transform

Repository: kobotoolbox/kpi

Length of output: 41


🏁 Script executed:

# Check the Price schema definition around line 23450-23470
sed -n '23440,23480p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 1655


🏁 Script executed:

# Check the PriceWithProduct schema around line 23510-23540
sed -n '23510,23550p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 1748


🏁 Script executed:

# Let's see what the git commit changed - check if we can see the previous state
git show HEAD:static/openapi/schema_v2.json | sed -n '23461,23463p' 2>/dev/null || echo "Cannot view previous version"

Repository: kobotoolbox/kpi

Length of output: 165


🏁 Script executed:

# Check the context where transform_quantity is fully defined (line 26398) to understand the schema pattern
sed -n '26380,26435p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 3910


🏁 Script executed:

# Let's understand what schema this full definition belongs to
sed -n '26300,26400p' static/openapi/schema_v2.json | tail -60

Repository: kobotoolbox/kpi

Length of output: 4611


🏁 Script executed:

# Check what schema contains the line 26398 definition by looking backwards
sed -n '26100,26400p' static/openapi/schema_v2.json | grep -n '"[A-Z][a-zA-Z]*":' | tail -20

Repository: kobotoolbox/kpi

Length of output: 102


🏁 Script executed:

# Let's find the parent schema name for line 26398
sed -n '26000,26398p' static/openapi/schema_v2.json | grep '"[A-Z][a-zA-Z]*":\s*{' | tail -5

Repository: kobotoolbox/kpi

Length of output: 207


🏁 Script executed:

# Check if there are any $ref references to a TransformQuantity component definition
rg '\$ref.*TransformQuantity' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 41


🏁 Script executed:

# Check the context before line 26398 to find the parent schema
sed -n '26200,26300p' static/openapi/schema_v2.json | grep -E '^\s*"[^"]+": \{$' | tail -10

Repository: kobotoolbox/kpi

Length of output: 614


🏁 Script executed:

# Find the schema name that contains line 26398
awk 'NR <= 26398 { line=$0; nr=NR } NR > 26398 { if (line ~ /"[A-Z][a-zA-Z]*": \{/) { print nr": "line; exit } }' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 41


🏁 Script executed:

# Let's look at what's around line 26100 to find the schema header
sed -n '26100,26150p' static/openapi/schema_v2.json | grep -B 50 -A 5 '"[A-Z][a-zA-Z]*": {' | tail -20

Repository: kobotoolbox/kpi

Length of output: 41


🏁 Script executed:

# Better approach: search backwards from line 26398 for a top-level schema definition
python3 << 'EOF'
with open('static/openapi/schema_v2.json', 'r') as f:
    lines = f.readlines()
    # Find the schema containing line 26398 (0-indexed: 26397)
    depth = 0
    for i in range(26397, -1, -1):
        line = lines[i]
        # Count braces
        depth += line.count('{') - line.count('}')
        if '"' in line and '": {' in line and depth == 1:
            print(f"Line {i+1}: {line.strip()}")
            break
EOF

Repository: kobotoolbox/kpi

Length of output: 94


🏁 Script executed:

# Let's look at a broader range to find the schema name containing transform_quantity at line 26398
sed -n '26200,26250p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 2163


🏁 Script executed:

# Look further back for the schema definition
sed -n '26000,26100p' static/openapi/schema_v2.json | tail -50

Repository: kobotoolbox/kpi

Length of output: 1687


🏁 Script executed:

# Let's look at lines around 26398 more carefully to understand the context
sed -n '26350,26450p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 7286


🏁 Script executed:

# Search for what schema names exist around that area
sed -n '26000,27000p' static/openapi/schema_v2.json | grep -E '^\s{12}"[A-Z][a-zA-Z]*":\s*\{' | head -20

Repository: kobotoolbox/kpi

Length of output: 426


🏁 Script executed:

# Let me find the parent schema for line 26398 by looking at indentation pattern
python3 << 'EOF'
with open('static/openapi/schema_v2.json', 'r') as f:
    lines = f.readlines()
    
# Look at indentation to find parent
for i in range(26398, 25900, -1):
    line = lines[i]
    # Count leading spaces
    indent = len(line) - len(line.lstrip())
    # Find schema definition at 12-space indent (parent level)
    if indent == 12 and '": {' in line:
        print(f"Line {i+1}: {line.rstrip()}")
        break
EOF

Repository: kobotoolbox/kpi

Length of output: 111


🏁 Script executed:

# Let's check if the simplified version at lines 23461-23463 uses a $ref instead
sed -n '23459,23465p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 321


🏁 Script executed:

# Same for the other location
sed -n '23523,23529p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 304


🏁 Script executed:

# Find which schema contains line 23461
python3 << 'EOF'
with open('static/openapi/schema_v2.json', 'r') as f:
    lines = f.readlines()
    
for i in range(23460, 23000, -1):
    line = lines[i]
    indent = len(line) - len(line.lstrip())
    if indent == 12 and '": {' in line:
        print(f"Line {i+1}: {line.rstrip()}")
        break
EOF

Repository: kobotoolbox/kpi

Length of output: 93


🏁 Script executed:

# Find which schema contains line 23525
python3 << 'EOF'
with open('static/openapi/schema_v2.json', 'r') as f:
    lines = f.readlines()
    
for i in range(23524, 23000, -1):
    line = lines[i]
    indent = len(line) - len(line.lstrip())
    if indent == 12 and '": {' in line:
        print(f"Line {i+1}: {line.rstrip()}")
        break
EOF

Repository: kobotoolbox/kpi

Length of output: 104


🏁 Script executed:

# Now let's verify the actual issue - check if there was a full schema before and it was removed
# Look at the Price schema completely
sed -n '23430,23475p' static/openapi/schema_v2.json | head -50

Repository: kobotoolbox/kpi

Length of output: 1889


🏁 Script executed:

# Let's verify the complete Price schema definition
sed -n '23412,23475p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 2591


🏁 Script executed:

# And the complete PriceWithProduct schema
sed -n '23477,23540p' static/openapi/schema_v2.json

Repository: kobotoolbox/kpi

Length of output: 2558


Don't replace transform_quantity with a bare nullable flag.

Lines 23461 and 23525 remove the field's schema definition entirely. This breaks code generation and validation for clients; they'll have no way to know transform_quantity should be an object with divide_by and round properties. The same field is properly defined elsewhere in the schema (line 26398) with type: "object" and properties. Keep that structure and mark it nullable instead.

Also applies to: 23525-23527

Comment on lines 12584 to 12600
description:
type: string
nullable: true
description: A description of this object.
readOnly: true
type:
allOf:
- $ref: '#/components/schemas/StripeProductType'
description: |-
The type of the product. The product is either of type `good`, which is eligible for use with Orders and SKUs, or `service`, which is eligible for use with Subscriptions and Plans.

* `good` - Good
* `service` - Service
type: string
nullable: true
metadata:
type: object
additionalProperties:
type: string
readOnly: true
required:
- description
- id
- metadata
- name
- type

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and examine the specific lines
wc -l static/openapi/schema_v2.yaml

Repository: kobotoolbox/kpi

Length of output: 94


🏁 Script executed:

# Examine the BaseProduct schema at lines 12584-12600
sed -n '12584,12600p' static/openapi/schema_v2.yaml

Repository: kobotoolbox/kpi

Length of output: 402


🏁 Script executed:

# Examine the Product schema at lines 16850-16871
sed -n '16850,16871p' static/openapi/schema_v2.yaml

Repository: kobotoolbox/kpi

Length of output: 519


🏁 Script executed:

# Let's also check the Price schema at lines 16754-16789
sed -n '16754,16789p' static/openapi/schema_v2.yaml

Repository: kobotoolbox/kpi

Length of output: 1074


🏁 Script executed:

# Check the .editorconfig file for linting rules
cat .editorconfig

Repository: kobotoolbox/kpi

Length of output: 386


🌐 Web query:

Stripe API Product object description field nullable

💡 Result:

Yes. In Stripe’s API, Product.description is a nullable string, so it can be null when unset. [1]

When updating a product, Stripe’s docs also show description being set to null (e.g., in the Node example), indicating you can clear it by updating with description: null. [2]

Sources:
[1] Stripe API Reference — Product object (description (string, nullable))
[2] Stripe API Reference — Update a product (Node example includes "description": null)


🌐 Web query:

Stripe API SubscriptionItem quantity field nullable

💡 Result:

Yes. In Stripe’s API, SubscriptionItem.quantity is defined as integer, nullable on the Subscription Item object. [1]

In practice, quantity is typically only meaningful/set for recurring[usage_type]=licensed (per-seat). For recurring[usage_type]=metered, you can’t specify a quantity (billing is based on usage records), so quantity may be absent/null depending on how you retrieve/deserialize the object. [2]

Sources:
[1] Stripe API Reference – “The Subscription Item object” (quantity (integer, nullable)) (docs.stripe.com)
[2] Stripe Billing Docs – “Set product or subscription quantities” (quantity only for usage_type=licensed) (docs.stripe.com)

Citations:


🏁 Script executed:

# Check SubscriptionItem schema around lines 17945-17952
sed -n '17940,17960p' static/openapi/schema_v2.yaml

Repository: kobotoolbox/kpi

Length of output: 504


🏁 Script executed:

# Let's also look at a broader context around SubscriptionItem to see the full schema
sed -n '17900,17970p' static/openapi/schema_v2.yaml

Repository: kobotoolbox/kpi

Length of output: 2272


Add nullable: true to description on Stripe products.

Stripe's API documents product description as a nullable string, and the actual API can return "description": null. Without nullable: true, both BaseProduct and Product schemas are stricter than the API they describe, which breaks generated clients and tests for valid products without a description.

Suggested fix
         description:
           type: string
+          nullable: true
           readOnly: true

Also applies to: Product schema (16850–16871)

… casts and fix test fixture data
@coderabbitai

coderabbitai Bot commented Mar 24, 2026

Copy link
Copy Markdown
Contributor

CodeRabbit chat interactions are restricted to organization members for this repository. Ask an organization member to interact with CodeRabbit, or set chat.allow_non_org_members: true in your configuration.

Comment thread jsapp/js/api/models/subscriptionItem.ts
Comment on lines +227 to 231
start=F(
'djstripe_customers__subscriptions__stripe_data__current_period_start'
),
end=F('djstripe_customers__subscriptions__stripe_data__current_period_end'),
interval=F(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 start/end JSON lookups not explicitly cast to integer — inconsistent with start_date

start_date uses Cast(KeyTextTransform(...), output_field=BigIntegerField()) to guarantee a typed integer for the Window-function comparison. However, start and end use plain F() expressions on the same JSONField:

start=F('djstripe_customers__subscriptions__stripe_data__current_period_start'),
end=F('djstripe_customers__subscriptions__stripe_data__current_period_end'),

These values are later passed directly to datetime.fromtimestamp(res['start'], ...), which requires a numeric type. While psycopg2 will typically return a Python int for a JSON integer, the behaviour is implicitly relied upon rather than enforced. If the JSONB value were stored as a float or if psycopg2's JSON type-coercion behaved differently, fromtimestamp would receive an unexpected type at runtime.

For consistency and robustness, consider applying the same Cast(KeyTextTransform(...), output_field=BigIntegerField()) pattern used for start_date:

start=Cast(
    KeyTextTransform(
        'current_period_start',
        'djstripe_customers__subscriptions__stripe_data',
    ),
    output_field=BigIntegerField(),
),
end=Cast(
    KeyTextTransform(
        'current_period_end',
        'djstripe_customers__subscriptions__stripe_data',
    ),
    output_field=BigIntegerField(),
),

'latest_invoice', sub.stripe_data->>'latest_invoice',
'pending_setup_intent', sub.stripe_data->>'pending_setup_intent',
'plan', sub.stripe_data->>'plan',
'default_tax_rates', COALESCE(sub.stripe_data->'default_tax_rates', '[]'::jsonb)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 default_tax_rates data shape has changed — downstream consumers may break

Previously, default_tax_rates was assembled by JOINing djstripe_djstripesubscriptiondefaulttaxrate and djstripe_taxrate, producing a full jsonb_agg of rich TaxRate objects (id, description, display_name, inclusive, percentage, etc.).

With this change it becomes:

'default_tax_rates', COALESCE(sub.stripe_data->'default_tax_rates', '[]'::jsonb)

In Stripe's raw API response (which dj-stripe 2.10 stores verbatim in stripe_data), default_tax_rates is an array of expanded TaxRate objects only if Stripe was asked to expand it during sync. If the subscription was synced without expansion, default_tax_rates may contain just an array of ID strings, or may be absent entirely, resulting in '[]'::jsonb.

Any code (external reporting, admin dashboards, etc.) that previously expected full TaxRate objects (e.g. rate.percentage, rate.display_name) from the materialized-view subscriptions JSON will now receive a structurally different payload or an empty array. If this data is surfaced via the user-reports API, it's worth verifying that consumers are tolerant of both shapes.

@noliveleger noliveleger force-pushed the django-5.2-update-stripe branch from eebd5a1 to c329d25 Compare March 24, 2026 19:31

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
static/openapi/schema_v2.json (1)

23970-23972: ⚠️ Potential issue | 🟠 Major

Restore the transform_quantity object schema.

{"nullable": true} removes the field’s shape entirely. In OpenAPI 3.0, nullable only applies when the same schema also declares a type, so tooling will either ignore it or treat transform_quantity as effectively untyped. Keep the object schema here and mark that object nullable instead.

OpenAPI 3.0 Schema Object: does `nullable: true` have any effect when `type` is omitted, and what is the correct way to represent a nullable object property?

Also applies to: 24034-24036

🧹 Nitpick comments (3)
.github/workflows/openapi.yml (1)

46-49: Python 3.12 upgrade looks good; stale comment on line 73-75.

The upgrade to Python 3.12 aligns with the CI modernization effort. However, the comment on lines 73-75 now references an outdated Python version:

# temporary 20260227: Let the CI pass with this warning:
#   > FutureWarning: You are using a Python version (3.10.19) which Google will stop supporting

Consider updating or removing this comment since the workflow now runs on Python 3.12, making the warning context no longer accurate.

,

kobo/apps/stripe/utils/subscription_limits.py (1)

315-327: Potential N+1 query issue with subscription items iteration.

The loop at lines 316-319 iterates over subscriptions and then over their items. While select_related('price__product') is used on the items queryset, the subscriptions queryset itself doesn't prefetch items, potentially causing N+1 queries.

♻️ Suggested fix to prefetch items
 `@requires_stripe`
 def get_plan_name(org_user: OrganizationUser, **kwargs) -> str | None:
     Subscription = kwargs['subscription_model']
     subscriptions = Subscription.objects.filter(
         customer__subscriber_id=org_user.organization.id,
         stripe_data__status__in=ACTIVE_STRIPE_STATUSES,
+    ).prefetch_related(
+        'items__price__product'
     )
 
     unique_products = set()
     for subscription in subscriptions:
-        for item in subscription.items.select_related('price__product').all():
+        for item in subscription.items.all():
             if item.price and item.price.product:
                 unique_products.add(item.price.product)
kobo/apps/stripe/utils/billing_dates.py (1)

224-240: Inconsistent JSON field extraction patterns—consider standardizing for code clarity.

Lines 224-230 use F('stripe_data__billing_cycle_anchor') directly in values(), while line 234-240 uses KeyTextTransform + Cast for start_date. While both approaches work correctly with Django 5.2 (F() properly extracts integer types from JSON), standardizing to KeyTextTransform + Cast for all timestamp fields would improve consistency and make the data type handling more explicit, especially since this pattern is already used for the Max() aggregation.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 99624b74-4b7e-40b4-879f-1203603abaff

📥 Commits

Reviewing files that changed from the base of the PR and between b5d1b76 and faf06e0.

📒 Files selected for processing (18)
  • .github/workflows/darker.yml
  • .github/workflows/openapi.yml
  • dependencies/pip/dev_requirements.in
  • dependencies/pip/dev_requirements.txt
  • dependencies/pip/requirements.in
  • dependencies/pip/requirements.txt
  • jsapp/js/api/models/subscriptionItem.ts
  • kobo/apps/accounts/utils.py
  • kobo/apps/organizations/models.py
  • kobo/apps/stripe/serializers.py
  • kobo/apps/stripe/tests/test_stripe_utils.py
  • kobo/apps/stripe/tests/utils.py
  • kobo/apps/stripe/utils/billing_dates.py
  • kobo/apps/stripe/utils/subscription_limits.py
  • kobo/apps/stripe/views.py
  • kobo/apps/user_reports/tests/test_user_reports.py
  • static/openapi/schema_v2.json
  • static/openapi/schema_v2.yaml
✅ Files skipped from review due to trivial changes (2)
  • dependencies/pip/dev_requirements.in
  • .github/workflows/darker.yml
🚧 Files skipped from review as they are similar to previous changes (5)
  • jsapp/js/api/models/subscriptionItem.ts
  • kobo/apps/organizations/models.py
  • kobo/apps/user_reports/tests/test_user_reports.py
  • kobo/apps/accounts/utils.py
  • static/openapi/schema_v2.yaml

Comment thread dependencies/pip/requirements.in
Comment on lines 18020 to +18022
"type": {
"allOf": [
{
"$ref": "#/components/schemas/StripeProductType"
}
],
"description": "The type of the product. The product is either of type `good`, which is eligible for use with Orders and SKUs, or `service`, which is eligible for use with Subscriptions and Plans.\n\n* `good` - Good\n* `service` - Service"
"type": "string",
"nullable": true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Official Stripe API docs: what values can Product.type and Price.type return, and does dj-stripe 2.10 change those enums or only the backing storage to stripe_data?

💡 Result:

  • Stripe Product.type can be good or service. Stripe describes type as required, with good for products used with Orders/SKUs and service for products used with subscriptions/plans. [1]
  • Stripe Price.type can be one_time or recurring. [2]

dj-stripe 2.10 and enums: dj-stripe 2.10.0 (2025-08-11) is primarily a storage/ORM-compatibility step: it removes many concrete DB columns and accesses Stripe fields via the stripe_data JSONField (and encourages filtering via stripe_data__...). It does not redefine Stripe’s enum values for fields like product/price “type”; it’s about where/how the data is stored/accessed. [3] Additionally, dj-stripe notes “All enums are now set to 255 max length” (a DB/schema compatibility change, not a change to the allowed enum members). [4]

Sources:
[1] Stripe changelog entry describing Product.type as good or service.
[2] Stripe API reference for the Price object: type is one_time or recurring.
[3] dj-stripe 2.10.0 change notes explaining shift to stripe_data + property accessors.
[4] dj-stripe release notes mentioning enum fields max length set to 255.


Restore type enum references instead of using nullable string.

Stripe's Product.type is strictly good or service; Price.type is strictly one_time or recurring. The dj-stripe 2.10 migration to stripe_data is a storage and ORM change only—it does not alter Stripe's enum contract. Replacing the enum refs with nullable string unnecessarily weakens the generated public contract and removes useful type information for clients. Keep StripeProductType and StripePriceType enum references and add nullability around the refs if required.

Also applies to: 23938-23940, 24003-24005, 24066-24068

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
static/openapi/schema_v2.yaml (1)

12870-12883: ⚠️ Potential issue | 🟠 Major

description is still modeled too strictly for Stripe products

At Line 12870 and Line 17144, description is type: string and required (Line 12882, Line 17160), but not nullable. If Stripe returns null, generated clients will reject valid responses.

Suggested fix
     BaseProduct:
       type: object
       properties:
         description:
           type: string
+          nullable: true
           readOnly: true
@@
     Product:
       type: object
       properties:
         description:
           type: string
+          nullable: true
           readOnly: true
In Stripe's current API reference, is Product.description nullable and can API responses include "description": null?

Also applies to: 17144-17161


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0b15b89d-88ed-4d92-806a-c1bf6dcfbcce

📥 Commits

Reviewing files that changed from the base of the PR and between faf06e0 and b1a24e1.

📒 Files selected for processing (7)
  • kobo/apps/user_reports/migrations/0001_squashed_old_migrations.py
  • kobo/apps/user_reports/migrations/0002_reset_mv_for_djstripe_update.py
  • kobo/apps/user_reports/migrations/0007_fix_mfa_is_active_new_table.py
  • kobo/apps/user_reports/tests/test_user_reports.py
  • kobo/apps/user_reports/utils/migrations.py
  • static/openapi/schema_v2.json
  • static/openapi/schema_v2.yaml
🚧 Files skipped from review as they are similar to previous changes (1)
  • static/openapi/schema_v2.json

Comment thread kobo/apps/user_reports/migrations/0001_squashed_old_migrations.py
max_length=32,
),
),
('last_processed_org_id', models.CharField(blank=True, null=True)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In Django 5.2, does models.CharField require an explicit max_length in migration field definitions? What error or system check should we expect if it is omitted?

💡 Result:

Yes, in Django 5.2, models.CharField requires an explicit max_length in model field definitions. Omitting it triggers a system check error during commands like makemigrations or manage.py check. The expected error is a SystemCheckError with the message: "CharFields must define a 'max_length' attribute." and error code fields.E120. For example: SystemCheckError: System check identified some issues: ERRORS: app.Model.field_name: (fields.E120) CharFields must define a 'max_length' attribute.

Citations:


🏁 Script executed:

# Check if the file exists and examine its contents around line 55
if [ -f "kobo/apps/user_reports/migrations/0001_squashed_old_migrations.py" ]; then
  echo "=== File found. Examining lines 50-60 ==="
  sed -n '50,60p' "kobo/apps/user_reports/migrations/0001_squashed_old_migrations.py" | cat -n
  echo ""
  echo "=== Checking for CharField definitions without max_length in this file ==="
  grep -n "CharField" "kobo/apps/user_reports/migrations/0001_squashed_old_migrations.py" | head -20
else
  echo "File not found at expected path"
fi

Repository: kobotoolbox/kpi

Length of output: 1514


Add explicit max_length to the CharField on line 55.

last_processed_org_id is defined as models.CharField(blank=True, null=True) without a max_length parameter. Django requires this parameter and will raise a SystemCheckError (fields.E120) during migration application. Other CharField definitions in this file specify max_length, so add it here as well (e.g., max_length=255 or an appropriate length for an org ID).

Comment on lines +17 to +21
dependencies = [('user_reports', '0001_squashed_old_migrations')]

operations = [
migrations.RunSQL(DROP_MV_SQL, reverse_sql=''),
migrations.RunPython(reset_lrm_0019, reverse_code=migrations.RunPython.noop),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that this migration touches long_running_migrations.LongRunningMigration
# while its dependency list only mentions user_reports.
rg -n -C2 "dependencies =|long_running_migrations|RunPython" \
  kobo/apps/user_reports/migrations/0002_reset_mv_for_djstripe_update.py

Repository: kobotoolbox/kpi

Length of output: 556


🏁 Script executed:

# Find the long_running_migrations app and its migrations
find . -type d -name "long_running_migrations" | head -20

Repository: kobotoolbox/kpi

Length of output: 94


🏁 Script executed:

# List migrations in long_running_migrations if it exists
fd -type f "migrations/" . | grep long_running_migrations | head -20

Repository: kobotoolbox/kpi

Length of output: 229


🏁 Script executed:

# Search for where LongRunningMigration model is defined
rg "class LongRunningMigration" --type py

Repository: kobotoolbox/kpi

Length of output: 759


🏁 Script executed:

# Check other migrations that reference long_running_migrations to see how they handle dependencies
rg "long_running_migrations" kobo/apps/*/migrations/ --type py -l

Repository: kobotoolbox/kpi

Length of output: 1990


🏁 Script executed:

# List migrations in long_running_migrations ordered by name
ls -1 kobo/apps/long_running_migrations/migrations/ | grep -E "^[0-9]+" | sort -n | head -5

Repository: kobotoolbox/kpi

Length of output: 279


🏁 Script executed:

# Check the initial migration file that likely creates the model
cat kobo/apps/long_running_migrations/migrations/0001_initial.py 2>/dev/null | head -40

Repository: kobotoolbox/kpi

Length of output: 1178


🏁 Script executed:

# Search for other migrations that reference long_running_migrations model to see dependency patterns
rg -A5 "long_running_migrations.*LongRunningMigration" kobo/apps/long_running_migrations/migrations/ --type py | head -40

Repository: kobotoolbox/kpi

Length of output: 3917


🏁 Script executed:

# Check the dependencies of user_reports.0001_squashed_old_migrations
rg -A10 "^class Migration" kobo/apps/user_reports/migrations/0001_squashed_old_migrations.py | head -20

Repository: kobotoolbox/kpi

Length of output: 548


🏁 Script executed:

# Check the full dependencies declaration in 0001_squashed_old_migrations
rg "dependencies = " -A 5 kobo/apps/user_reports/migrations/0001_squashed_old_migrations.py

Repository: kobotoolbox/kpi

Length of output: 243


Add the migration dependency for long_running_migrations.0001_initial.

reset_lrm_0019() calls apps.get_model('long_running_migrations', 'LongRunningMigration') but the dependencies at line 17 only reference user_reports.0001_squashed_old_migrations, which does not depend on the long_running_migrations app. On a fresh database install, Django may attempt to run this migration before the LongRunningMigration model is created, causing apps.get_model() to fail.

Add ('long_running_migrations', '0001_initial') to the dependencies list.

🧰 Tools
🪛 Ruff (0.15.7)

[warning] 17-17: Mutable default value for class attribute

(RUF012)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
kobo/apps/user_reports/migrations/0002_reset_mv_for_djstripe_update.py (1)

18-25: ⚠️ Potential issue | 🔴 Critical

Missing migration dependency for long_running_migrations app.

reset_lrm_0019() calls apps.get_model('long_running_migrations', 'LongRunningMigration'), but the dependencies list at line 29 only includes user_reports.0001_squashed_old_migrations. On a fresh database, Django may run this migration before the LongRunningMigration model exists, causing apps.get_model() to raise LookupError.

Add the dependency:

 class Migration(migrations.Migration):
-    dependencies = [('user_reports', '0001_squashed_old_migrations')]
+    dependencies = [
+        ('user_reports', '0001_squashed_old_migrations'),
+        ('long_running_migrations', '0001_initial'),
+    ]

Also applies to: 28-29


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d96c2505-1cc2-4ef4-8bff-2c9685657817

📥 Commits

Reviewing files that changed from the base of the PR and between b1a24e1 and aecf2b0.

📒 Files selected for processing (1)
  • kobo/apps/user_reports/migrations/0002_reset_mv_for_djstripe_update.py

@noliveleger noliveleger force-pushed the django-5.2-update-stripe branch from aecf2b0 to 64c36f0 Compare April 2, 2026 15:39
'djstripe_customers__subscriptions__stripe_data__billing_cycle_anchor'
),
start=F(
'djstripe_customers__subscriptions__stripe_data__current_period_start'

@jamesrkiger jamesrkiger Apr 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure these current_period... lookups are wrong. As far as I can tell, current_period_end and current_period_start are nested (very unhelpfully) under stripe_data["items"]["data"][*]. That is an issue in itself. But I'm more concerned that it doesn't seem to have been caught by any of our tests, because we use a lot of mocked data for Stripe-side objects in our tests. And that makes me worry about anything else we might miss.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interloper comment, but I think we're okay with these ORM lookups based on dj-stripe's own use of the stripe_data dictionary: https://github.com/dj-stripe/dj-stripe/blob/stable/2.10/djstripe/models/billing.py#L1250-L1258, e.g. self.stripe_data.get("current_period_end")

we use a lot of mocked data for Stripe-side objects in our tests

This certainly is a weakness. I wonder if there's any way for us to use work that dj-stripe is presumably already doing to have up-to-date fixtures for their own tests? Could we compare the shape of those fixtures to some expectation as part of our unit tests?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jnm Good catch. I did not realize they still have that property there, but now I'm even more confused because as far as I can see it doesn't work:

In [9]: sub.current_period_start

In [10]: "current_period_start" in sub.stripe_data
Out[10]: False

The subscription-level billing period data was removed in a Stripe API release last year. So presumably these properties could be there for some backwards compatibility? But when I boot up KPI locally and sync Stripe data, djstripe uses a more recent API version that doesn't actually have those subscription-level keys anymore.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oo 👀 I'll butt out as I don't have time to fire up everything locally right now with this branch. If you and @noliveleger want to have a discussion about Stripe testing overall and think I'd be helpful from an architectural standpoint, LMK on Zulip

Base automatically changed from django-5.2 to release/2.026.13 April 24, 2026 07:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants