Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .agents/skills/paykit-architecture/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: paykit-architecture
description: Use before architectural, API design, provider integration, billing lifecycle, database model, or product-scope decisions in PayKit.
description: Not use automatically.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the skill description aligned with the file's actual intent.

description: Not use automatically. now conflicts with the guidance below telling readers to use this skill for architecture and provider-integration work. If this front matter drives skill discovery, this change makes the right skill harder to invoke for exactly the scenarios this file documents.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.agents/skills/paykit-architecture/SKILL.md at line 3, The front-matter key
"description" currently reads "Not use automatically." which conflicts with the
documented purpose; update the SKILL.md front matter so the description
accurately summarizes the file's intent (e.g., that this skill is for
architecture and provider-integration guidance) so discovery and invocation work
correctly—locate the "description" field in the SKILL.md front matter and
replace the text with a concise, intent-aligned phrase describing
architecture/provider-integration usage.

---

# PayKit Architecture
Expand Down
10 changes: 5 additions & 5 deletions apps/web/content/docs/providers/polar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ POLAR_ACCESS_TOKEN=polar_oat_...
POLAR_WEBHOOK_SECRET=...
```

- `POLAR_ACCESS_TOKEN`: create one in [Polar Settings](https://polar.sh/settings) under **Access Tokens**. The token needs the following scopes: `products:read`, `products:write`, `customers:read`, `customers:write`, `customer_sessions:write`, `subscriptions:read`, `subscriptions:write`, `checkouts:write`, `organizations:read`, `organizations:write`.
- `POLAR_ACCESS_TOKEN`: create one in [Polar Settings](https://polar.sh/settings) under **Access Tokens**. The token needs the following scopes: `products:read`, `products:write`, `customers:read`, `customers:write`, `customer_sessions:write`, `subscriptions:read`, `subscriptions:write`, `checkouts:write`, `organizations:read`, `organizations:write`, `webhooks:read`, `webhooks:write`.
- `POLAR_WEBHOOK_SECRET`: generated when you create a webhook endpoint. See the section below.

## Webhook setup
Expand All @@ -60,16 +60,16 @@ You can also select all events. PayKit silently ignores any events it doesn't ne

## Local development

Use the Polar CLI to forward webhook events to your local server:
Use the PayKit CLI to create or update the Polar webhook endpoint and forward events to your local server:

```bash
polar listen http://localhost:3000/paykit/webhook
paykitjs listen --forward-to http://localhost:3000
```

The CLI prints a webhook signing secret at startup. Use that as `POLAR_WEBHOOK_SECRET` in your local `.env`.
The CLI prints the provider webhook URL and webhook signing secret at startup. Use that signing secret as `POLAR_WEBHOOK_SECRET` in your local `.env`.

<Callout type="info">
Install the Polar CLI with `curl -fsSL https://polar.sh/install.sh | bash`. You'll need to run `polar login` once to authenticate.
`paykitjs listen` uses your configured `POLAR_ACCESS_TOKEN`, so the token must include `webhooks:read` and `webhooks:write` in addition to the normal PayKit provider scopes.
</Callout>

## Product syncing
Expand Down
158 changes: 81 additions & 77 deletions e2e/core/cancel/cancel-then-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,93 +7,97 @@ import {
expectProduct,
expectSingleActivePlanInGroup,
expectSingleScheduledPlanInGroup,
harness,
subscribeCustomer,
type TestPayKit,
} from "../../test-utils";

describe("cancel-then-upgrade: pro → free (scheduled) → ultra (upgrade)", () => {
let t: TestPayKit;
let customerId: string;
describe.skipIf(!harness.capabilities.repeatedHostedCheckout)(
"cancel-then-upgrade: pro → free (scheduled) → ultra (upgrade)",
() => {
let t: TestPayKit;
let customerId: string;

beforeAll(async () => {
t = await createTestPayKit();
const customer = await createTestCustomerWithPM({
t,
customer: {
id: "test_cancel_upgrade",
email: "cancel-upgrade@test.com",
name: "Cancel Then Upgrade Test",
},
});
customerId = customer.customerId;
beforeAll(async () => {
t = await createTestPayKit();
const customer = await createTestCustomerWithPM({
t,
customer: {
id: "test_cancel_upgrade",
email: "cancel-upgrade@test.com",
name: "Cancel Then Upgrade Test",
},
});
customerId = customer.customerId;

// Setup: subscribe to Pro, then schedule downgrade to Free
await subscribeCustomer({ t, customerId, planId: "pro" });
// Setup: subscribe to Pro, then schedule downgrade to Free
await subscribeCustomer({ t, customerId, planId: "pro" });

await subscribeCustomer({ t, customerId, planId: "free" });
});
await subscribeCustomer({ t, customerId, planId: "free" });
});

afterAll(async () => {
await t?.cleanup();
});
afterAll(async () => {
await t?.cleanup();
});

it("upgrading while cancellation is pending cancels the downgrade and activates the new plan", async () => {
try {
// Verify precondition
await expectProduct({
database: t.database,
customerId,
planId: "pro",
expected: { status: "active", canceled: true },
});
await expectSingleActivePlanInGroup({
database: t.database,
customerId,
group: "base",
planId: "pro",
});
await expectSingleScheduledPlanInGroup({
database: t.database,
customerId,
group: "base",
planId: "free",
});
it("upgrading while cancellation is pending cancels the downgrade and activates the new plan", async () => {
try {
// Verify precondition
await expectProduct({
database: t.database,
customerId,
planId: "pro",
expected: { status: "active", canceled: true },
});
await expectSingleActivePlanInGroup({
database: t.database,
customerId,
group: "base",
planId: "pro",
});
await expectSingleScheduledPlanInGroup({
database: t.database,
customerId,
group: "base",
planId: "free",
});

// Action: upgrade to Ultra
await subscribeCustomer({ t, customerId, planId: "ultra" });
// Action: upgrade to Ultra
await subscribeCustomer({ t, customerId, planId: "ultra" });

// Ultra is active
await expectProduct({
database: t.database,
customerId,
planId: "ultra",
expected: {
status: "active",
hasPeriodEnd: true,
},
});
await expectSingleActivePlanInGroup({
database: t.database,
customerId,
group: "base",
planId: "ultra",
});
// Ultra is active
await expectProduct({
database: t.database,
customerId,
planId: "ultra",
expected: {
status: "active",
hasPeriodEnd: true,
},
});
await expectSingleActivePlanInGroup({
database: t.database,
customerId,
group: "base",
planId: "ultra",
});

// Pro is ended
await expectProduct({
database: t.database,
customerId,
planId: "pro",
expected: { status: "ended" },
});
// Pro is ended
await expectProduct({
database: t.database,
customerId,
planId: "pro",
expected: { status: "ended" },
});

// TODO: scheduled Free should be deleted on upgrade, but the subscribe
// flow computes "switch" instead of "upgrade" when the current subscription
// has cancel_at_period_end=true. This is a known PayKit issue.
// await expectProductNotPresent(t.database, customerId, "free");
} catch (error) {
await dumpStateOnFailure(t.database, t.dbPath);
throw error;
}
});
});
// TODO: scheduled Free should be deleted on upgrade, but the subscribe
// flow computes "switch" instead of "upgrade" when the current subscription
// has cancel_at_period_end=true. This is a known PayKit issue.
// await expectProductNotPresent(t.database, customerId, "free");
} catch (error) {
await dumpStateOnFailure(t.database, t.dbPath);
throw error;
}
});
},
);
Loading
Loading