Skip to content

feat(merchant-migration): map Stripe products onto existing Polar catalog (PLR-116) - #14323

Closed
psincraian wants to merge 12 commits into
mainfrom
cursor/map-existing-products-backend-6c64
Closed

psincraian wants to merge 12 commits into
mainfrom
cursor/map-existing-products-backend-6c64

Conversation

@psincraian

@psincraian psincraian commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Related Issue: PLR-116

Map Stripe catalog products onto existing Polar products during merchant migration, so imported subscribers land on the Polar product IDs the merchant already uses.

What

  • Persist mappings on MerchantMigration.source_credentials["product_mappings"] (no alembic).
  • Auto-suggest a unique name among interval-compatible products, else a unique amount+currency+interval match. Name collision without a unique compatible match blocks import until the merchant maps or explicitly creates a new product.
  • On reuse, grandfather the Stripe amount as an archived catalog ProductPriceFixed. Ignore ad-hoc checkout prices when matching.
  • Pin imported subscriptions to exclusive tax.
  • Private GET/PUT /v1/merchant-migrations/{id}/product-mappings. Regenerated @polar-sh/client so OpenAPI CI stays in sync.

Why

Merchants already selling on Polar need Stripe subscriptions to land on the same Polar product IDs their app uses for benefits. Creating a duplicate Polar product would split benefits and plan resolution.

How

Grain is one Polar product = one CanonicalProduct (prod_…:month:1). Interval and currency must match; amount need not. decide_mapping order: explicit UUID → explicit None (create-new) → unique compatible name → unique amount+currency+interval → else create-new / requires_choice.

Dashboard mapping UI is a stacked follow-up PR against this branch.

Checklist

  • This PR addresses a single concern (one bug fix, one feature, one refactor)
  • The diff is reasonably sized and easy to review
  • New functionality is covered by tests
  • Linting and type checking pass (uv run task lint && uv run task lint_types)
  • No unrelated changes or drive-by fixes are included
Open in Web Open in Cursor 

Review in cubic

cursoragent and others added 12 commits September 9, 2026 10:58
…alog

Merchants already selling on Polar need Stripe subscriptions to land on
the same Polar product IDs their app uses for benefits. Auto-suggest a
unique amount/currency/interval match, and require an explicit choice
when a name collides without a compatible price.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
… Polar

Map Stripe products onto existing Polar products even when Polar's catalog
price has moved on. Imported subscribers keep the Stripe amount via an
archived catalog price; checkout keeps selling the current catalog price.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Drop the card and matching-rules copy. Show a table-density row with a
muted catalog price when amounts differ, and hide unique same-price matches.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Show Stripe products that still have subscribers in a small table:
name, interval, price, subscription count, and Polar product mapping.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Cap the mapping table at five products and only show prev/next when
there are more. Tighter rows replace the full DataTable chrome.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Drop the catalog price caption, shrink the Stripe product column, and
label the review table as Subscriptions under Product configuration.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Use CSS subgrid so Polar product selects share one column width instead of sizing independently per row.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Use the same viewing label and chevron controls as the review DataTable, without a rows-per-page selector.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Widen those tracks so a ~1000 subscriber count fits, and increase column gap.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Drop frontend mapping tests and unit-level mapping permutations. Keep the service and HTTP cases that cover suggest, grandfather, create-new, and required choice. Reuse is_recurring_product and precheck product_by_price_key.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Match grandfathered Stripe amounts on catalog prices only, so an ad-hoc
checkout price cannot skip the archived catalog row or become the billable
price. Pin imported subscriptions to exclusive tax. Unify polar_product_id
on read and write, type Polar intervals as the recurring enum, and drop
import-time mapping payloads. Remove the mapping pager and unused wrappers.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
Keep merchant-migration mapping API, importer, and generated client.
Leave dashboard mapping UI for the frontend PR.

Co-authored-by: Petru Rares Sincraian <psincraian@outlook.com>
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
orbit Ready Ready Preview Sep 9, 2026 7:32pm UTC
polar-test Ready Ready Preview Sep 9, 2026 7:32pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

OpenAPI Changes

Operations

  • 🔀 POST /v1/merchant-migrations/{id}/import (modified)
    • 🔀 Response 400 modified
      • description: The source is not connected or isn't supported.The source is not connected, isn't supported, or a product mapping is invalid.
    • 🔀 Response 409 modified
      • description: The pre-check hasn't run yet, it reports a blocker, or another job is still running.The pre-check hasn't run yet, it reports a blocker, another job is still running, or an imported product mapping can't change.
  • 🔼 GET /v1/merchant-migrations/{id}/product-mappings (added)
  • 🔼 PUT /v1/merchant-migrations/{id}/product-mappings (added)

Schemas

  • 🔼 MerchantMigrationMappedPrice (added)
  • 🔼 MerchantMigrationPolarProductOption (added)
  • 🔼 MerchantMigrationProductMappingChoice (added)
  • 🔼 MerchantMigrationProductMappingItem (added)
  • 🔼 MerchantMigrationProductMappingList (added)
  • 🔼 MerchantMigrationProductMappingUpdate (added)
  • 🔼 ProductMappingIncompatibility (added)
  • 🔼 ProductMappingInvalid (added)
  • 🔼 ProductMappingLocked (added)

@cubic-dev-ai cubic-dev-ai 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.

5 issues found across 13 files

Confidence score: 3/5

  • server/polar/merchant_migration/schemas.py can mark a dropped tiered, metered, or unsupported price as importable when a product also has a valid fixed price, which could mislead migration decisions—filter prices using the product’s importable-price criteria.
  • server/polar/merchant_migration/product_mapping.py drops staged subscriptions without currency instead of resolving them through price_source_id, so the mapping UI may report zero subscribers—reuse the precheck’s source-price fallback.
  • server/polar/merchant_migration/service.py materializes and deserializes every staged subscription to compute product counts, creating avoidable memory and latency risk for large migrations—stream the subscriptions or aggregate counts in SQL.
  • handbook/engineering/design-documents/merchant-migrations.mdx describes tax_behavior as universally exclusive despite the Taxes section’s per-bucket rules, while server/polar/merchant_migration/endpoints.py omits documented 401 responses; align the design guidance with the authoritative tax rules and add Unauthorized.schema() to both OpenAPI response blocks.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="handbook/engineering/design-documents/merchant-migrations.mdx">

<violation number="1" location="handbook/engineering/design-documents/merchant-migrations.mdx:184">
P3: The new Product mapping section claims imported subscriptions pin `tax_behavior` exclusive as a general rule, but the Taxes section (the authority on this) decides tax behavior per-bucket: inclusive for EU / no-tax merchants, exclusive only for US, with the point being to set the value explicitly rather than inherit the org default. Read literally, 'pin tax_behavior exclusive' for all imported subscriptions contradicts the existing per-bucket decision and would re-tax inclusive EU cohorts. Scope the sentence (e.g. 'set the resolved per-bucket value explicitly, so the first Polar cycle does not inherit the org default') or drop the exclusive qualifier so the mapping section and the Taxes section agree.</violation>
</file>

<file name="server/polar/merchant_migration/endpoints.py">

<violation number="1" location="server/polar/merchant_migration/endpoints.py:238">
P3: Unauthenticated requests to both new product-mapping routes return 401, but neither route declares that response. Add `Unauthorized.schema()` to both `responses` blocks so OpenAPI and generated clients describe the actual authentication contract.</violation>
</file>

<file name="server/polar/merchant_migration/schemas.py">

<violation number="1" location="server/polar/merchant_migration/schemas.py:453">
P2: When a source product has both an importable fixed price and a dropped tiered, metered, or unsupported price, this response labels the dropped price as importable. Filter the values used for `prices` with the product import plan, or expose the pricing status so mapping clients do not treat dropped prices as importable.</violation>
</file>

<file name="server/polar/merchant_migration/product_mapping.py">

<violation number="1" location="server/polar/merchant_migration/product_mapping.py:138">
P2: When a staged subscription has no currency, this branch drops it instead of resolving its product by `price_source_id`. Use the same source-price fallback as the precheck, or the mapping UI reports zero subscribers and can mislead reuse decisions.</violation>
</file>

<file name="server/polar/merchant_migration/service.py">

<violation number="1" location="server/polar/merchant_migration/service.py:829">
P2: On large migrations, this endpoint materializes and deserializes every staged subscription just to compute product counts. Stream subscriptions or aggregate counts in SQL instead.

(Based on your team's feedback about streaming large batches.)</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

description="How many `recurring_interval` units each period spans."
)
prices: list[MerchantMigrationMappedPrice] = Field(
description="Importable fixed prices on this source product."

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: When a source product has both an importable fixed price and a dropped tiered, metered, or unsupported price, this response labels the dropped price as importable. Filter the values used for prices with the product import plan, or expose the pricing status so mapping clients do not treat dropped prices as importable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/polar/merchant_migration/schemas.py, line 453:

<comment>When a source product has both an importable fixed price and a dropped tiered, metered, or unsupported price, this response labels the dropped price as importable. Filter the values used for `prices` with the product import plan, or expose the pricing status so mapping clients do not treat dropped prices as importable.</comment>

<file context>
@@ -378,3 +402,106 @@ class MerchantMigration(IDSchema, TimestampedSchema):
+        description="How many `recurring_interval` units each period spans."
+    )
+    prices: list[MerchantMigrationMappedPrice] = Field(
+        description="Importable fixed prices on this source product."
+    )
+    subscriber_count: int = Field(
</file context>

counts: Counter[str] = Counter()
for subscription in subscriptions:
key = subscription_price_key(subscription)
product = product_by_price.get(key) if key is not None else None

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: When a staged subscription has no currency, this branch drops it instead of resolving its product by price_source_id. Use the same source-price fallback as the precheck, or the mapping UI reports zero subscribers and can mislead reuse decisions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/polar/merchant_migration/product_mapping.py, line 138:

<comment>When a staged subscription has no currency, this branch drops it instead of resolving its product by `price_source_id`. Use the same source-price fallback as the precheck, or the mapping UI reports zero subscribers and can mislead reuse decisions.</comment>

<file context>
@@ -0,0 +1,191 @@
+    counts: Counter[str] = Counter()
+    for subscription in subscriptions:
+        key = subscription_price_key(subscription)
+        product = product_by_price.get(key) if key is not None else None
+        if product is not None:
+            counts[product.source_id] += 1
</file context>

canonical_products = [
self._as_canonical_product(record) for record in product_records
]
subscriptions = [

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: On large migrations, this endpoint materializes and deserializes every staged subscription just to compute product counts. Stream subscriptions or aggregate counts in SQL instead.

(Based on your team's feedback about streaming large batches.)

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/polar/merchant_migration/service.py, line 829:

<comment>On large migrations, this endpoint materializes and deserializes every staged subscription just to compute product counts. Stream subscriptions or aggregate counts in SQL instead.

(Based on your team's feedback about streaming large batches.) </comment>

<file context>
@@ -665,6 +724,161 @@ async def import_catalog(
+        canonical_products = [
+            self._as_canonical_product(record) for record in product_records
+        ]
+        subscriptions = [
+            deserialize(record.type, record.canonical)
+            for record in records
</file context>


Grain is one Polar product = one source product + interval (`CanonicalProduct.source_id`, e.g. `prod_…:month:1`). Interval and currency must match to map. Amount need not: Polar's catalog can have moved on (Stripe still $5, Polar now sells $10). Imported subscribers keep the Stripe amount via an archived catalog price on that product; checkout keeps offering the active catalog price. Do not add a new currency onto the Polar product.

Quantity, tax, and trial stay on the subscription / pre-check: Polar products don't carry per-subscription quantity, and cutover already preserves period and trial dates. Imported subscriptions pin `tax_behavior` exclusive so the first Polar cycle does not inherit the org default.

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.

P3: The new Product mapping section claims imported subscriptions pin tax_behavior exclusive as a general rule, but the Taxes section (the authority on this) decides tax behavior per-bucket: inclusive for EU / no-tax merchants, exclusive only for US, with the point being to set the value explicitly rather than inherit the org default. Read literally, 'pin tax_behavior exclusive' for all imported subscriptions contradicts the existing per-bucket decision and would re-tax inclusive EU cohorts. Scope the sentence (e.g. 'set the resolved per-bucket value explicitly, so the first Polar cycle does not inherit the org default') or drop the exclusive qualifier so the mapping section and the Taxes section agree.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At handbook/engineering/design-documents/merchant-migrations.mdx, line 184:

<comment>The new Product mapping section claims imported subscriptions pin `tax_behavior` exclusive as a general rule, but the Taxes section (the authority on this) decides tax behavior per-bucket: inclusive for EU / no-tax merchants, exclusive only for US, with the point being to set the value explicitly rather than inherit the org default. Read literally, 'pin tax_behavior exclusive' for all imported subscriptions contradicts the existing per-bucket decision and would re-tax inclusive EU cohorts. Scope the sentence (e.g. 'set the resolved per-bucket value explicitly, so the first Polar cycle does not inherit the org default') or drop the exclusive qualifier so the mapping section and the Taxes section agree.</comment>

<file context>
@@ -175,6 +175,27 @@ Precheck and catalog import run as **Dramatiq background jobs** (batched self-re
+
+Grain is one Polar product = one source product + interval (`CanonicalProduct.source_id`, e.g. `prod_…:month:1`). Interval and currency must match to map. Amount need not: Polar's catalog can have moved on (Stripe still $5, Polar now sells $10). Imported subscribers keep the Stripe amount via an archived catalog price on that product; checkout keeps offering the active catalog price. Do not add a new currency onto the Polar product.
+
+Quantity, tax, and trial stay on the subscription / pre-check: Polar products don't carry per-subscription quantity, and cutover already preserves period and trial dates. Imported subscriptions pin `tax_behavior` exclusive so the first Polar cycle does not inherit the org default.
+
+UI options considered:
</file context>
Suggested change
Quantity, tax, and trial stay on the subscription / pre-check: Polar products don't carry per-subscription quantity, and cutover already preserves period and trial dates. Imported subscriptions pin `tax_behavior` exclusive so the first Polar cycle does not inherit the org default.
Imported subscriptions pin the resolved per-bucket `tax_behavior` explicitly so the first Polar cycle does not inherit the org default.

"/{id}/product-mappings",
response_model=MerchantMigrationProductMappingList,
summary="List Merchant Migration Product Mappings",
responses={

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.

P3: Unauthenticated requests to both new product-mapping routes return 401, but neither route declares that response. Add Unauthorized.schema() to both responses blocks so OpenAPI and generated clients describe the actual authentication contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/polar/merchant_migration/endpoints.py, line 238:

<comment>Unauthenticated requests to both new product-mapping routes return 401, but neither route declares that response. Add `Unauthorized.schema()` to both `responses` blocks so OpenAPI and generated clients describe the actual authentication contract.</comment>

<file context>
@@ -220,6 +231,65 @@ async def import_catalog(
+    "/{id}/product-mappings",
+    response_model=MerchantMigrationProductMappingList,
+    summary="List Merchant Migration Product Mappings",
+    responses={
+        403: {
+            "description": "Not allowed to manage this organization.",
</file context>

@mintlify

mintlify Bot commented Sep 9, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
Handbook 🟢 Ready View Preview Sep 9, 2026, 7:39 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@cursor cursor Bot changed the title feat(merchant-migration): map Stripe products onto existing Polar catalog feat(merchant-migration): map Stripe products onto existing Polar catalog (PLR-116) Sep 10, 2026
@psincraian psincraian closed this Sep 14, 2026

This branch was successfully deployed

3 active deployments
staging - handbook f77efb3d Deployed Sep 9, 2026 by mintlify[bot]
Preview – polar-test f77efb3d Deployed Sep 9, 2026 by vercel[bot]
Preview – orbit f77efb3d Deployed Sep 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants