Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/polar-encore/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# @polar-sh/encore

## 0.1.0

### Minor Changes

- Initial release of Polar adapter for Encore.ts
- Checkout sessions with automatic redirects
- Customer portal integration
- Webhook handling with signature verification
- Full TypeScript support with 14 passing tests
174 changes: 174 additions & 0 deletions packages/polar-encore/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# @polar-sh/encore

Payments and Checkouts made dead simple with Encore.ts.

`npm install @polar-sh/encore`

## Setup

Set your Polar credentials using Encore's secrets:

```bash
encore secret set --type local PolarAccessToken
encore secret set --type local PolarWebhookSecret
```

Get your credentials from [Polar Settings → API](https://polar.sh/settings/api).

## Checkout

Create a Checkout handler which takes care of redirections.

```typescript
import { api } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { Checkout } from "@polar-sh/encore";

const polarAccessToken = secret("PolarAccessToken");

export const checkout = api.raw(
{ expose: true, path: "/checkout", method: "GET" },
Checkout({
accessToken: polarAccessToken(),
successUrl: "https://myapp.com/success",
returnUrl: "https://myapp.com",
server: "sandbox",
theme: "dark",
})
);
```

### Query Params

Pass query params to this route.

- products `?products=123`
- customerId (optional) `?products=123&customerId=xxx`
- customerExternalId (optional) `?products=123&customerExternalId=xxx`
- customerEmail (optional) `?products=123&customerEmail=janedoe@gmail.com`
- customerName (optional) `?products=123&customerName=Jane`
- seats (optional) `?products=123&seats=5` - Number of seats for seat-based products
- metadata (optional) `URL-Encoded JSON string`

## Customer Portal

Create a customer portal where your customer can view orders and subscriptions.

```typescript
import { api } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { CustomerPortal } from "@polar-sh/encore";
import { getAuthData } from "~encore/auth";

const polarAccessToken = secret("PolarAccessToken");

export const portal = api.raw(
{ expose: true, path: "/portal", method: "GET", auth: true },
CustomerPortal({
accessToken: polarAccessToken(),
getCustomerId: async (req) => {
const auth = getAuthData();
return auth!.polarCustomerId;
},
returnUrl: "https://myapp.com/dashboard",
server: "sandbox",
})
);
```

## Webhooks

Handle Polar webhooks with automatic signature verification.

```typescript
import { api } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { Webhooks } from "@polar-sh/encore";

const polarWebhookSecret = secret("PolarWebhookSecret");

export const webhooks = api.raw(
{ expose: true, path: "/webhooks/polar", method: "POST" },
Webhooks({
webhookSecret: polarWebhookSecret(),
onPayload: async (payload) => {
console.log("Received webhook:", payload.type);
},
})
);
```

### Payload Handlers

The Webhook handler also supports granular handlers for easy integration.

- onCheckoutCreated: (payload) =>
- onCheckoutUpdated: (payload) =>
- onOrderCreated: (payload) =>
- onOrderUpdated: (payload) =>
- onOrderPaid: (payload) =>
- onSubscriptionCreated: (payload) =>
- onSubscriptionUpdated: (payload) =>
- onSubscriptionActive: (payload) =>
- onSubscriptionCanceled: (payload) =>
- onSubscriptionRevoked: (payload) =>
- onProductCreated: (payload) =>
- onProductUpdated: (payload) =>
- onBenefitCreated: (payload) =>
- onBenefitUpdated: (payload) =>
- onBenefitGrantCreated: (payload) =>
- onBenefitGrantUpdated: (payload) =>
- onBenefitGrantRevoked: (payload) =>
- onCustomerCreated: (payload) =>
- onCustomerUpdated: (payload) =>
- onCustomerDeleted: (payload) =>
- onCustomerStateChanged: (payload) =>

### Example with Specific Handlers

```typescript
import { api } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { Webhooks } from "@polar-sh/encore";
import { db } from "./db";

const polarWebhookSecret = secret("PolarWebhookSecret");

export const webhooks = api.raw(
{ expose: true, path: "/webhooks/polar", method: "POST" },
Webhooks({
webhookSecret: polarWebhookSecret(),
onSubscriptionActive: async (payload) => {
await db.exec`
UPDATE users
SET subscription_status = 'active',
subscription_id = ${payload.data.id}
WHERE polar_customer_id = ${payload.data.customerId}
`;
},
onSubscriptionCanceled: async (payload) => {
await db.exec`
UPDATE users
SET subscription_status = 'canceled'
WHERE subscription_id = ${payload.data.id}
`;
},
})
);
```

## Full Example

See the `/example` directory for a complete working example of integrating Polar with Encore.ts, including:
- Checkout sessions with product selection
- Customer portal access
- Webhook handling for subscriptions
- Database integration for user management
- Feature gating based on subscription status

## Learn More

- [Polar Documentation](https://polar.sh/docs)
- [Encore.ts Documentation](https://encore.dev/docs/ts)
- [Polar API Reference](https://polar.sh/docs/api-reference)

56 changes: 56 additions & 0 deletions packages/polar-encore/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"name": "@polar-sh/encore",
"version": "0.1.0",
"description": "Polar.sh adapter for Encore.ts - Handle payments, subscriptions, and benefits",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module",
"exports": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"engines": {
"node": ">=16"
},
"scripts": {
"build": "tsup ./src/index.ts --format esm,cjs --dts --clean --sourcemap",
"test": "vitest run",
"test:watch": "vitest",
"dev": "tsc --watch",
"check": "biome check --write ./src"
},
"keywords": [
"encore",
"polar",
"payments",
"subscriptions",
"merchant-of-record",
"checkout",
"stripe"
],
"author": "Polar",
"license": "MIT",
"peerDependencies": {
"encore.dev": "^1.0.0"
},
"dependencies": {
"@polar-sh/sdk": "^0.41.5"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/node": "^20.0.0",
"tsup": "^8.5.0",
"typescript": "^5.0.0",
"vitest": "^2.1.8"
}
}
123 changes: 123 additions & 0 deletions packages/polar-encore/src/checkout/checkout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Checkout } from "./checkout.js";
import type { RawRequest, RawResponse } from "encore.dev/api";

vi.mock("@polar-sh/sdk", () => ({
Polar: vi.fn().mockImplementation(() => ({
checkouts: {
create: vi.fn().mockResolvedValue({
url: "https://checkout.polar.sh/test",
}),
},
})),
}));

describe("Checkout", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("should return 400 if no products are provided", async () => {
const checkout = Checkout({
accessToken: "test-token",
server: "sandbox",
});

const req = {
headers: {
host: "localhost",
},
url: "/checkout",
} as RawRequest;

const resp = {
writeHead: vi.fn(),
end: vi.fn(),
} as unknown as RawResponse;

await checkout(req, resp);

expect(resp.writeHead).toHaveBeenCalledWith(400, {
"Content-Type": "application/json",
});
expect(resp.end).toHaveBeenCalledWith(
JSON.stringify({
error: "Missing products in query params",
}),
);
});

it("should redirect to checkout URL with products", async () => {
const checkout = Checkout({
accessToken: "test-token",
server: "sandbox",
});

const req = {
headers: {
host: "localhost",
},
url: "/checkout?products=prod-1&products=prod-2",
} as RawRequest;

const resp = {
writeHead: vi.fn(),
end: vi.fn(),
} as unknown as RawResponse;

await checkout(req, resp);

expect(resp.writeHead).toHaveBeenCalledWith(302, {
Location: expect.stringContaining("https://checkout.polar.sh/test"),
});
expect(resp.end).toHaveBeenCalled();
});

it("should create checkout handler with successUrl", () => {
const checkout = Checkout({
accessToken: "test-token",
successUrl: "https://example.com/success",
includeCheckoutId: true,
server: "sandbox",
});

expect(checkout).toBeDefined();
expect(typeof checkout).toBe("function");
});

it("should create checkout handler with theme parameter", () => {
const checkout = Checkout({
accessToken: "test-token",
theme: "dark",
server: "sandbox",
});

expect(checkout).toBeDefined();
expect(typeof checkout).toBe("function");
});

it("should handle request with optional query parameters", async () => {
const checkout = Checkout({
accessToken: "test-token",
server: "sandbox",
});

const req = {
headers: {
host: "localhost",
},
url: "/checkout?products=prod-1&customerEmail=test@example.com&seats=5",
} as RawRequest;

const resp = {
writeHead: vi.fn(),
end: vi.fn(),
} as unknown as RawResponse;

await checkout(req, resp);

expect(resp.writeHead).toHaveBeenCalled();
expect(resp.end).toHaveBeenCalled();
});
});

Loading