mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(core,api): take payment, and let the provider decide who is paid up
Checkout, the customer portal, and the webhook that moves a team's plan. Nothing money-shaped is stored. No price, no currency, no card detail — a subscription's existence and its state are the whole of what crosses back, because they are the only two facts the product needs and anything more would be a second copy of a record somebody else is authoritative for. Currency is deliberately not ours to hold. A product carries a price per currency on their side and the customer's location picks one at checkout, so there is no figure in this API that could drift from the one somebody is charged. The team id travels as the customer's external id, which keeps the mapping on their side rather than putting a foreign primary key in our schema. Access follows their state, and the interesting cases are where that is not the same as "paying right now". Cancelling keeps the plan: they paid to the end of the period and turning them off when they click it takes something they bought. A failed card keeps it too, because a retry that ends in payment should not have cost them access in the middle. Only a revoked subscription takes it away, which is the one moment nothing is left that was paid for. An event we do not recognise changes nothing at all — new types are added by people who do not know what we do with them, and a default that moved a plan would eventually cancel an account nobody cancelled. The webhook is the only route here no session protects, because its caller has no account and never will. A signature over the raw body stands in for one, and it is checked before the body is looked at — a body that has been parsed and re-serialized is not the body that was signed. With no secret configured it refuses everything rather than accepting anything, since otherwise knowing the URL would be enough to set somebody's plan. Note also what is absent: no route sets a plan, so there is no endpoint for granting yourself a subscription. The product is written down as a definition with a script rather than clicked into a dashboard, because the two environments are separate servers and nothing made in one can be moved to the other. Promoting it is running the same script with the other token, which is the only version of that which cannot drift. It writes nothing without --apply and refuses to add a second product with a name already taken.
This commit is contained in:
104
packages/core/scripts/polar-product.ts
Normal file
104
packages/core/scripts/polar-product.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Create the paid product, against whichever environment you point it at.
|
||||
*
|
||||
* Sandbox and production are separate servers with separate data, so nothing
|
||||
* made in one can be moved to the other — a product designed in sandbox has to
|
||||
* be *recreated* in production, and two things typed twice are two things that
|
||||
* drift. So the product is written down once here, and promoting it is running
|
||||
* this again with the other token.
|
||||
*
|
||||
* POLAR_ACCESS_TOKEN=$(cat ~/.polar_sandbox_token) \
|
||||
* POLAR_SERVER=sandbox \
|
||||
* bun run packages/core/scripts/polar-product.ts
|
||||
*
|
||||
* Add `--apply` to actually create it. Without it nothing is written and the
|
||||
* script prints what it would do, because this talks to a live payment account
|
||||
* and a product created by accident is visible to customers.
|
||||
*
|
||||
* It refuses to make a second product with the same name rather than quietly
|
||||
* making a duplicate — two products called the same thing is how a checkout
|
||||
* ends up pointing at the wrong one.
|
||||
*/
|
||||
import { Polar } from '@polar-sh/sdk';
|
||||
import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presentmentcurrency.js';
|
||||
|
||||
/**
|
||||
* The paid rung of the self-serve ladder.
|
||||
*
|
||||
* One recurring monthly product, priced per currency. The customer's location
|
||||
* picks which price they see, so these are *presentment* prices rather than a
|
||||
* conversion of one another — that is the point of listing three rather than
|
||||
* charging one and letting a card issuer decide.
|
||||
*
|
||||
* Amounts are in minor units: 2000 is 20.00.
|
||||
*/
|
||||
const PRODUCT = {
|
||||
name: 'Nestri Pro',
|
||||
description: 'Cloud sessions on Nestri hardware, and a larger burn allowance.',
|
||||
recurringInterval: 'month' as const,
|
||||
prices: [
|
||||
{ currency: 'usd', amount: 2000 },
|
||||
{ currency: 'eur', amount: 2000 },
|
||||
{ currency: 'gbp', amount: 2000 }
|
||||
] satisfies { currency: PresentmentCurrency; amount: number }[]
|
||||
};
|
||||
|
||||
const apply = process.argv.includes('--apply');
|
||||
const accessToken = process.env.POLAR_ACCESS_TOKEN;
|
||||
const server = (process.env.POLAR_SERVER ?? 'sandbox') as 'sandbox' | 'production';
|
||||
|
||||
if (!accessToken) {
|
||||
console.error('POLAR_ACCESS_TOKEN is not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const polar = new Polar({ accessToken, server });
|
||||
|
||||
const organizations = await polar.organizations.listOrganizations({ limit: 2 });
|
||||
const organization = organizations.result.items.at(0);
|
||||
if (!organization) {
|
||||
console.error('that token can see no organization');
|
||||
process.exit(1);
|
||||
}
|
||||
if (organizations.result.items.length > 1) {
|
||||
// Which one to use would be a guess, and the wrong guess bills the wrong
|
||||
// company.
|
||||
console.error('that token can see more than one organization; refusing to choose');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`server: ${server}`);
|
||||
console.log(`organization: ${organization.name} (${organization.id})`);
|
||||
|
||||
const existing = await polar.products.list({ organizationId: organization.id, limit: 100 });
|
||||
const clash = existing.result.items.find((p) => p.name === PRODUCT.name && !p.isArchived);
|
||||
if (clash) {
|
||||
console.log(`\nalready there: ${PRODUCT.name} (${clash.id})`);
|
||||
console.log('nothing to do. Archive it first if you meant to replace it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\nwould create: ${PRODUCT.name}, every ${PRODUCT.recurringInterval}`);
|
||||
for (const price of PRODUCT.prices) {
|
||||
console.log(` ${price.currency.toUpperCase()} ${(price.amount / 100).toFixed(2)}`);
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log('\nnothing written. Re-run with --apply to create it.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const created = await polar.products.create({
|
||||
organizationId: organization.id,
|
||||
name: PRODUCT.name,
|
||||
description: PRODUCT.description,
|
||||
recurringInterval: PRODUCT.recurringInterval,
|
||||
prices: PRODUCT.prices.map((price) => ({
|
||||
amountType: 'fixed' as const,
|
||||
priceCurrency: price.currency,
|
||||
priceAmount: price.amount
|
||||
}))
|
||||
});
|
||||
|
||||
console.log(`\ncreated: ${created.id}`);
|
||||
console.log(`set POLAR_PRODUCT_ID=${created.id} for the ${server} deployment.`);
|
||||
Reference in New Issue
Block a user