diff --git a/packages/polar/src/__tests__/polar-provider.test.ts b/packages/polar/src/__tests__/polar-provider.test.ts new file mode 100644 index 0000000..48c8ccc --- /dev/null +++ b/packages/polar/src/__tests__/polar-provider.test.ts @@ -0,0 +1,224 @@ +import type { Polar } from "@polar-sh/sdk"; +import { HTTPValidationError } from "@polar-sh/sdk/models/errors/httpvalidationerror"; +import { validateEvent } from "@polar-sh/sdk/webhooks"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createPolarProvider, polar } from "../polar-provider"; + +vi.mock("@polar-sh/sdk/webhooks", () => ({ + validateEvent: vi.fn(), + WebhookVerificationError: class WebhookVerificationError extends Error {}, +})); + +type Page = { + result: { items: T[] }; + next: () => null; + [Symbol.asyncIterator]: () => AsyncIterableIterator>; +}; + +function page(items: T[]): Page { + const result: Page = { + result: { items }, + next: () => null, + [Symbol.asyncIterator]: async function* iterator() { + yield result; + }, + }; + return result; +} + +function httpValidationError(): HTTPValidationError { + return new HTTPValidationError( + { detail: [{ loc: ["body", "email"], msg: "already exists", type: "value_error" }] }, + { + body: "{}", + request: new Request("https://api.polar.sh/v1/customers/"), + response: new Response("{}", { status: 422 }), + }, + ); +} + +describe("@paykitjs/polar", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a provider config with createAdapter", () => { + const config = polar({ + accessToken: "polar_test_123", + webhookSecret: "whsec_test_123", + server: "sandbox", + }); + + expect(config.id).toBe("polar"); + expect(config.name).toBe("Polar"); + expect(config.capabilities.testClocks).toBe(false); + expect(typeof config.createAdapter).toBe("function"); + }); + + it("includes subscription data on successful checkout webhooks", async () => { + const subscriptions = { + get: vi.fn().mockResolvedValue({ + cancelAtPeriodEnd: false, + canceledAt: null, + currentPeriodEnd: "2026-06-01T00:00:00.000Z", + currentPeriodStart: "2026-05-01T00:00:00.000Z", + endedAt: null, + id: "sub_123", + productId: "prod_123", + status: "active", + }), + }; + const client = { subscriptions } as unknown as Polar; + vi.mocked(validateEvent).mockReturnValue({ + type: "checkout.updated", + data: { + id: "chk_123", + customerId: "cus_123", + metadata: { + paykit_customer_id: "customer_123", + paykit_intent: "subscribe", + paykit_plan_id: "pro", + }, + status: "succeeded", + subscriptionId: "sub_123", + }, + } as unknown as ReturnType); + + const provider = createPolarProvider(client, { + accessToken: "polar_test_123", + webhookSecret: "whsec_test_123", + }); + + const events = await provider.handleWebhook({ + body: "{}", + headers: { "webhook-id": "evt_123" }, + }); + + expect(subscriptions.get).toHaveBeenCalledWith({ id: "sub_123" }); + expect(events).toHaveLength(1); + expect(events[0]?.payload).toMatchObject({ + checkoutSessionId: "chk_123", + providerCustomerId: "cus_123", + providerEventId: "evt_123", + providerSubscriptionId: "sub_123", + subscription: { + providerProduct: { productId: "prod_123" }, + providerSubscriptionId: "sub_123", + status: "active", + }, + }); + }); + + it("re-links an existing customer when Polar rejects a duplicate", async () => { + const existingCustomer = { + id: "cus_existing", + email: "person@example.com", + externalId: null, + metadata: { existing: "value" }, + }; + const customers = { + create: vi.fn().mockRejectedValue(httpValidationError()), + list: vi.fn().mockResolvedValue(page([existingCustomer])), + update: vi.fn().mockResolvedValue({ id: "cus_existing" }), + }; + const client = { customers } as unknown as Polar; + const provider = createPolarProvider(client, { + accessToken: "polar_test_123", + webhookSecret: "whsec_test_123", + }); + + const result = await provider.createCustomer({ + id: "paykit_customer_123", + email: "person@example.com", + name: "Person", + metadata: { plan: "pro" }, + }); + + expect(result.providerCustomer.id).toBe("cus_existing"); + expect(customers.list).toHaveBeenCalledWith({ email: "person@example.com", limit: 1 }); + expect(customers.update).toHaveBeenCalledWith({ + id: "cus_existing", + customerUpdate: { + name: "Person", + metadata: { + existing: "value", + paykitCustomerId: "paykit_customer_123", + plan: "pro", + }, + }, + }); + }); + + it("only archives PayKit-managed orphan products during sync", async () => { + const products = { + create: vi.fn(), + list: vi.fn().mockResolvedValue( + page([ + { + id: "prod_existing", + metadata: { paykitProductId: "basic" }, + recurringInterval: "month", + }, + { + id: "prod_unmanaged", + metadata: {}, + recurringInterval: "month", + }, + { + id: "prod_orphan", + metadata: { paykitProductId: "old" }, + recurringInterval: "month", + }, + ]), + ), + update: vi.fn().mockImplementation(({ id, productUpdate }) => + Promise.resolve({ + id, + metadata: productUpdate.metadata ?? {}, + recurringInterval: "month", + }), + ), + }; + const organizations = { + list: vi.fn().mockResolvedValue({ result: { items: [] } }), + }; + const client = { organizations, products } as unknown as Polar; + const provider = createPolarProvider(client, { + accessToken: "polar_test_123", + webhookSecret: "whsec_test_123", + }); + + const result = await provider.syncProducts({ + products: [ + { + id: "basic", + name: "Basic", + priceAmount: 1000, + priceInterval: "month", + existingProviderProduct: { productId: "prod_existing" }, + }, + ], + }); + + expect(result.results).toEqual([ + { id: "basic", providerProduct: { productId: "prod_existing" } }, + ]); + expect(products.create).not.toHaveBeenCalled(); + expect(products.update).toHaveBeenCalledWith({ + id: "prod_existing", + productUpdate: expect.objectContaining({ + metadata: { paykitProductId: "basic" }, + name: "Basic", + }), + }); + expect(products.update).toHaveBeenCalledWith({ + id: "prod_orphan", + productUpdate: { isArchived: true }, + }); + expect(products.update).not.toHaveBeenCalledWith({ + id: "prod_unmanaged", + productUpdate: { isArchived: true }, + }); + }); +}); diff --git a/packages/polar/src/polar-provider.ts b/packages/polar/src/polar-provider.ts index ccb668f..1016156 100644 --- a/packages/polar/src/polar-provider.ts +++ b/packages/polar/src/polar-provider.ts @@ -1,4 +1,5 @@ import { Polar } from "@polar-sh/sdk"; +import { HTTPValidationError } from "@polar-sh/sdk/models/errors/httpvalidationerror"; import { SDKValidationError } from "@polar-sh/sdk/models/errors/sdkvalidationerror"; import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks"; import { PayKitError, PAYKIT_ERROR_CODES } from "paykitjs"; @@ -17,13 +18,30 @@ export type PolarProviderConfig = PayKitProviderConfig & { type PolarWebhookEvent = ReturnType; type PolarSubscriptionEvent = Extract; type PolarCheckoutEvent = Extract; +type PolarCustomer = Awaited>["result"]["items"][number]; +type PolarProduct = Awaited>["result"]["items"][number]; +type PolarSubscription = Awaited>; +type PolarSubscriptionLike = Pick< + PolarSubscription, + | "cancelAtPeriodEnd" + | "canceledAt" + | "currentPeriodEnd" + | "currentPeriodStart" + | "endedAt" + | "id" + | "productId" + | "status" +>; + +const PAYKIT_CUSTOMER_METADATA_KEY = "paykitCustomerId"; +const PAYKIT_PRODUCT_METADATA_KEY = "paykitProductId"; function toDate(value: Date | string | null | undefined): Date | null { if (!value) return null; return value instanceof Date ? value : new Date(value); } -function normalizePolarSubscription(sub: PolarSubscriptionEvent["data"]) { +function normalizePolarSubscription(sub: PolarSubscriptionLike) { return { cancelAtPeriodEnd: sub.cancelAtPeriodEnd, canceledAt: toDate(sub.canceledAt), @@ -37,6 +55,32 @@ function normalizePolarSubscription(sub: PolarSubscriptionEvent["data"]) { }; } +async function findExistingCustomer( + client: Polar, + data: { email: string; id: string }, +): Promise { + const byEmail = await client.customers.list({ email: data.email, limit: 1 }); + const emailMatch = byEmail.result.items.find((customer) => customer.email === data.email); + if (emailMatch) return emailMatch; + + const byExternalId = await client.customers.list({ query: data.id, limit: 100 }); + return byExternalId.result.items.find((customer) => customer.externalId === data.id) ?? null; +} + +function isPotentialDuplicateCustomerError(error: unknown): error is HTTPValidationError { + return error instanceof HTTPValidationError && error.statusCode === 422; +} + +function normalizeMetadata( + metadata: Record | undefined, + paykitCustomerId: string, +): Record { + return { + ...metadata, + [PAYKIT_CUSTOMER_METADATA_KEY]: paykitCustomerId, + }; +} + function createSubscriptionEvents( event: { type?: string; data: PolarSubscriptionEvent["data"] }, webhookId: string, @@ -89,16 +133,21 @@ function createSubscriptionEvents( ]; } -function createCheckoutEvents( +async function createCheckoutEvents( + client: Polar, event: { type?: string; data: PolarCheckoutEvent["data"] }, webhookId: string, -): NormalizedWebhookEvent[] { +): Promise { const checkout = event.data; if (checkout.status !== "succeeded") return []; const providerCustomerId = checkout.customerId; if (!providerCustomerId) return []; + const subscription = checkout.subscriptionId + ? normalizePolarSubscription(await client.subscriptions.get({ id: checkout.subscriptionId })) + : undefined; + return [ { name: "checkout.completed", @@ -110,6 +159,7 @@ function createCheckoutEvents( providerEventId: webhookId, providerSubscriptionId: checkout.subscriptionId ?? undefined, status: checkout.status, + subscription, metadata: checkout.metadata ? Object.fromEntries(Object.entries(checkout.metadata).map(([k, v]) => [k, String(v)])) : undefined, @@ -126,6 +176,25 @@ function notSupported(method: string): never { ); } +async function listActiveProducts(client: Polar): Promise { + const products: PolarProduct[] = []; + const firstPage = await client.products.list({ isArchived: false, limit: 100 }); + + for await (const page of firstPage) { + products.push(...(page.result.items ?? [])); + } + + return products; +} + +function isPayKitManagedProduct(product: PolarProduct): boolean { + return typeof product.metadata[PAYKIT_PRODUCT_METADATA_KEY] === "string"; +} + +function productMetadata(productId: string): Record { + return { [PAYKIT_PRODUCT_METADATA_KEY]: productId }; +} + export function createPolarProvider(client: Polar, options: PolarOptions): PaymentProvider { return { id: "polar", @@ -141,14 +210,12 @@ export function createPolarProvider(client: Polar, options: PolarOptions): Payme ); } - const customerMetadata = { - ...data.metadata, - paykitCustomerId: data.id, - }; + const customerMetadata = normalizeMetadata(data.metadata, data.id); try { const customer = await client.customers.create({ email: data.email, + externalId: data.id, name: data.name, metadata: customerMetadata, }); @@ -157,25 +224,22 @@ export function createPolarProvider(client: Polar, options: PolarOptions): Payme providerCustomer: { id: customer.id }, }; } catch (error) { - if (!(error instanceof SDKValidationError)) throw error; + if (!isPotentialDuplicateCustomerError(error)) throw error; - // Duplicate email — find and re-link the existing customer. - const list = await client.customers.list({ query: data.email, limit: 1 }); - const existing = list.result.items[0]; + const existing = await findExistingCustomer(client, { email: data.email, id: data.id }); if (!existing) { - throw PayKitError.from( - "INTERNAL_SERVER_ERROR", - PAYKIT_ERROR_CODES.PROVIDER_CUSTOMER_NOT_FOUND, - "Failed to create or find customer on Polar", - ); + throw error; } await client.customers.update({ id: existing.id, customerUpdate: { name: data.name, - metadata: customerMetadata, + metadata: { + ...existing.metadata, + ...customerMetadata, + }, }, }); @@ -186,12 +250,17 @@ export function createPolarProvider(client: Polar, options: PolarOptions): Payme }, async updateCustomer(data) { + const existing = await client.customers.get({ id: data.providerCustomerId }); + await client.customers.update({ id: data.providerCustomerId, customerUpdate: { email: data.email, name: data.name, - metadata: data.metadata ?? {}, + metadata: { + ...existing.metadata, + ...data.metadata, + }, }, }); }, @@ -368,12 +437,12 @@ export function createPolarProvider(client: Polar, options: PolarOptions): Payme async syncProducts(data) { const [allPolarProducts, orgs] = await Promise.all([ - client.products.list({ isArchived: false, limit: 100 }), + listActiveProducts(client), client.organizations.list({ limit: 1 }), ]); const org = orgs.result.items?.[0]; - const polarProductMap = new Map((allPolarProducts.result.items ?? []).map((p) => [p.id, p])); + const polarProductMap = new Map(allPolarProducts.map((p) => [p.id, p])); const activeProductIds = new Set(); @@ -393,6 +462,7 @@ export function createPolarProvider(client: Polar, options: PolarOptions): Payme id: existingPolarProduct.id, productUpdate: { name: product.name, + metadata: productMetadata(product.id), visibility: "private", prices: [ { @@ -416,6 +486,7 @@ export function createPolarProvider(client: Polar, options: PolarOptions): Payme const created = await client.products.create({ name: product.name, + metadata: productMetadata(product.id), visibility: "private", recurringInterval: (product.priceInterval as "month" | "year") ?? null, prices: [ @@ -434,8 +505,8 @@ export function createPolarProvider(client: Polar, options: PolarOptions): Payme // Archive orphans + configure org settings in parallel const cleanup: Promise[] = []; - for (const [polarId] of polarProductMap) { - if (!activeProductIds.has(polarId)) { + for (const [polarId, polarProduct] of polarProductMap) { + if (isPayKitManagedProduct(polarProduct) && !activeProductIds.has(polarId)) { cleanup.push( client.products.update({ id: polarId, @@ -504,7 +575,7 @@ export function createPolarProvider(client: Polar, options: PolarOptions): Payme return createSubscriptionEvents(event, webhookId); case "checkout.created": case "checkout.updated": - return createCheckoutEvents(event, webhookId); + return createCheckoutEvents(client, event, webhookId); default: return []; }