feat(merchant-migration): map Stripe products onto existing Polar catalog (PLR-116) - #14323
psincraian wants to merge 12 commits into
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
OpenAPI ChangesOperations
Schemas
|
There was a problem hiding this comment.
5 issues found across 13 files
Confidence score: 3/5
server/polar/merchant_migration/schemas.pycan mark a dropped tiered, metered, or unsupported price as importable when a product also has a valid fixed price, which could mislead migration decisions—filterpricesusing the product’s importable-price criteria.server/polar/merchant_migration/product_mapping.pydrops staged subscriptions without currency instead of resolving them throughprice_source_id, so the mapping UI may report zero subscribers—reuse the precheck’s source-price fallback.server/polar/merchant_migration/service.pymaterializes 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.mdxdescribestax_behavioras universally exclusive despite the Taxes section’s per-bucket rules, whileserver/polar/merchant_migration/endpoints.pyomits documented 401 responses; align the design guidance with the authoritative tax rules and addUnauthorized.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." |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 = [ |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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>
| 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={ |
There was a problem hiding this comment.
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>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
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
MerchantMigration.source_credentials["product_mappings"](no alembic).ProductPriceFixed. Ignore ad-hoc checkout prices when matching.GET/PUT /v1/merchant-migrations/{id}/product-mappings. Regenerated@polar-sh/clientso 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_mappingorder: explicit UUID → explicitNone(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
uv run task lint && uv run task lint_types)