mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
Deliveries arrived intact, verified correctly, and applied to nobody. Checking the signature here rather than through the SDK means the body is exactly what was sent, and what is sent is snake_case. The SDK's parser renames fields to camelCase on the way through, so the reader written against it looked for `externalId` in a payload that says `external_id` — found nothing, decided the delivery was about a customer we did not create, and acknowledged it. That is the worst shape a bug of this kind can take. Every visible signal was healthy: a 200 back to the provider, no retries, no errors, and a plan that silently never changed. Both spellings are now read, so neither path can regress the other.
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
import { Polar as PolarSdk } from '@polar-sh/sdk';
|
|
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
|
|
import { Webhook as StandardWebhook } from 'standardwebhooks';
|
|
import z from 'zod';
|
|
|
|
import { Env } from '../env.js';
|
|
import { ErrorCodes, VisibleError } from '../error.js';
|
|
import { fn } from '../fn.js';
|
|
import { memo } from '../utils/memo.js';
|
|
|
|
/**
|
|
* The payment provider, and the only part of this codebase that talks to one.
|
|
*
|
|
* Everything money-shaped that is *not* here is deliberate. We store no price,
|
|
* no currency and no card detail: a subscription's existence and its state are
|
|
* the whole of what crosses back, because those 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 not our problem, by design.** A product carries a price per
|
|
* currency on their side and the customer's location picks one at checkout. So
|
|
* there is no currency in this file, none in the database, and no place where a
|
|
* rate could be stale — the alternative is holding prices in three currencies
|
|
* and discovering one of them is wrong from a customer.
|
|
*
|
|
* The team id goes over as the customer's `externalCustomerId`, which makes the
|
|
* mapping theirs to keep. A Polar customer id in our schema would be a foreign
|
|
* primary key we would then have to keep in step with a system we do not
|
|
* control.
|
|
*/
|
|
export namespace Polar {
|
|
/** Which Polar instance. Sandbox is a separate server with separate data. */
|
|
export const Server = z.enum(['sandbox', 'production']);
|
|
export type Server = z.infer<typeof Server>;
|
|
|
|
function settings() {
|
|
const env = Env.get();
|
|
if (!env.POLAR_ACCESS_TOKEN) {
|
|
throw new VisibleError(
|
|
'internal',
|
|
ErrorCodes.Server.DEPENDENCY_FAILURE,
|
|
'Billing is not configured'
|
|
);
|
|
}
|
|
return {
|
|
accessToken: env.POLAR_ACCESS_TOKEN,
|
|
server: Server.parse(env.POLAR_SERVER ?? 'sandbox'),
|
|
productId: env.POLAR_PRODUCT_ID,
|
|
freeProductId: env.POLAR_FREE_PRODUCT_ID,
|
|
webhookSecret: env.POLAR_WEBHOOK_SECRET
|
|
};
|
|
}
|
|
|
|
/** Whether billing can run at all. Every route here checks it first. */
|
|
export function configured(): boolean {
|
|
const env = Env.get();
|
|
return Boolean(env.POLAR_ACCESS_TOKEN && env.POLAR_PRODUCT_ID);
|
|
}
|
|
|
|
const client = memo(() => {
|
|
const { accessToken, server } = settings();
|
|
return new PolarSdk({ accessToken, server });
|
|
});
|
|
|
|
/** Reset the memo. Tests change the environment between cases. */
|
|
export function reset(): void {
|
|
client.reset();
|
|
}
|
|
|
|
/**
|
|
* Put a team on the free plan with the provider, without a checkout.
|
|
*
|
|
* A subscription at nothing a month needs no payment, so it is created
|
|
* outright rather than by sending somebody to pay zero — a checkout for a
|
|
* free account is a step that exists only to be got through.
|
|
*
|
|
* The point of doing it at all is that every team then exists on their side,
|
|
* with our team id as its external id. Free accounts show up in the same
|
|
* places paid ones do, an upgrade changes a subscription rather than
|
|
* inventing a customer, and there is one question to ask about anybody
|
|
* rather than two.
|
|
*
|
|
* **Idempotent, and quiet when it fails.** It runs after a team is created
|
|
* and must never be able to undo that: signing up is not allowed to depend
|
|
* on a third party being reachable, so a failure here leaves a team that is
|
|
* free anyway — which is exactly what it would have been — and the next call
|
|
* fixes it. That is also why it is safe to call on a team that already has
|
|
* one.
|
|
*/
|
|
export const ensureFree = fn(
|
|
z.object({ teamId: z.string(), email: z.email().optional() }),
|
|
async (input) => {
|
|
const { freeProductId } = settings();
|
|
if (!freeProductId) {
|
|
return { created: false, reason: 'no free product configured' as const };
|
|
}
|
|
|
|
// Already ours, and already subscribed to something.
|
|
try {
|
|
const state = await client().customers.getStateExternal({
|
|
externalId: input.teamId
|
|
});
|
|
if (state.activeSubscriptions.length > 0) {
|
|
return { created: false, reason: 'already subscribed' as const };
|
|
}
|
|
await client().subscriptions.create({
|
|
productId: freeProductId,
|
|
externalCustomerId: input.teamId
|
|
});
|
|
return { created: true, reason: 'subscribed an existing customer' as const };
|
|
} catch {
|
|
// No customer carries this team id yet, which is the ordinary
|
|
// case the first time. Fall through and find or make one.
|
|
}
|
|
|
|
// A customer may already exist under this address without being
|
|
// linked to anything of ours — made by hand, or left behind by a
|
|
// checkout taken before the team existed. Their addresses are unique,
|
|
// so creating a second one is refused rather than allowed, and the
|
|
// only way forward is to adopt the one that is there.
|
|
let customerId: string | null = null;
|
|
if (input.email) {
|
|
const found = await client().customers.list({ email: input.email, limit: 2 });
|
|
const existing = found.result.items.at(0);
|
|
if (existing) {
|
|
// **Never take one that belongs to another team.** Moving an
|
|
// external id would move where a subscription is billed, and
|
|
// the team losing it would go quiet rather than error.
|
|
if (existing.externalId && existing.externalId !== input.teamId) {
|
|
return { created: false, reason: 'address belongs to another team' as const };
|
|
}
|
|
if (!existing.externalId) {
|
|
await client().customers.update({
|
|
id: existing.id,
|
|
customerUpdate: { externalId: input.teamId }
|
|
});
|
|
}
|
|
customerId = existing.id;
|
|
}
|
|
}
|
|
|
|
if (!customerId) {
|
|
if (!input.email) {
|
|
// Without an address there is nothing to look up and nothing
|
|
// to create with, and guessing one would make a customer
|
|
// nobody can be reached at.
|
|
return { created: false, reason: 'no email to create a customer with' as const };
|
|
}
|
|
const made = await client().customers.create({
|
|
email: input.email,
|
|
externalId: input.teamId
|
|
});
|
|
customerId = made.id;
|
|
}
|
|
|
|
await client().subscriptions.create({
|
|
productId: freeProductId,
|
|
externalCustomerId: input.teamId
|
|
});
|
|
return { created: true, reason: 'created' as const };
|
|
}
|
|
);
|
|
|
|
/**
|
|
* A checkout for a team, as the customer they already are.
|
|
*
|
|
* The team id travels as `externalCustomerId`, so a second checkout for the
|
|
* same team reaches the same customer rather than making another one — which
|
|
* is what keeps one team from ending up with two subscriptions and two
|
|
* invoices for the same month.
|
|
*/
|
|
export const checkout = fn(
|
|
z.object({
|
|
teamId: z.string(),
|
|
email: z.email().optional(),
|
|
successUrl: z.url().optional()
|
|
}),
|
|
async (input) => {
|
|
const { productId } = settings();
|
|
if (!productId) {
|
|
throw new VisibleError(
|
|
'internal',
|
|
ErrorCodes.Server.DEPENDENCY_FAILURE,
|
|
'Billing is not configured'
|
|
);
|
|
}
|
|
const created = await client().checkouts.create({
|
|
products: [productId],
|
|
externalCustomerId: input.teamId,
|
|
customerEmail: input.email,
|
|
successUrl: input.successUrl
|
|
});
|
|
return { id: created.id, url: created.url };
|
|
}
|
|
);
|
|
|
|
/**
|
|
* A link to where somebody manages what they are already paying.
|
|
*
|
|
* Cancelling, changing a card and reading an invoice all live there rather
|
|
* than here. Rebuilding any of it would mean holding payment details to show
|
|
* them, which is the one thing this integration exists to avoid.
|
|
*/
|
|
export const portal = fn(z.object({ teamId: z.string() }), async (input) => {
|
|
const session = await client().customerSessions.create({
|
|
externalCustomerId: input.teamId
|
|
});
|
|
return { url: session.customerPortalUrl };
|
|
});
|
|
|
|
/** The plan and status a team is on, as far as the provider is concerned. */
|
|
export const Standing = z.object({
|
|
plan: z.enum(['free', 'paid']),
|
|
status: z.string()
|
|
});
|
|
|
|
export type Standing = z.infer<typeof Standing>;
|
|
|
|
/**
|
|
* What a subscription event means for what a team may do.
|
|
*
|
|
* The rule is that **access follows the provider's own state and nothing
|
|
* else**, and the interesting cases are the ones where that is not the same
|
|
* as "are they paying right now":
|
|
*
|
|
* - `canceled` keeps the plan. Somebody who cancels has paid to the end of
|
|
* the period and turning them off the moment they click it would be taking
|
|
* something they bought.
|
|
* - `past_due` also keeps it. A failed card is a card that may yet work, and
|
|
* a retry cycle that ends in payment should not have cost them access in
|
|
* the middle of it.
|
|
* - `revoked` is the one that takes it away. That is the provider saying the
|
|
* period is over and unpaid, which is the only moment there is nothing
|
|
* left that was paid for.
|
|
*
|
|
* An unknown type returns null rather than guessing. New event types get
|
|
* added by people who do not know what we do with them, and a default that
|
|
* changed somebody's plan would be a default that eventually cancels an
|
|
* account nobody cancelled.
|
|
*/
|
|
export function standingFor(eventType: string, productId: string | null): Standing | null {
|
|
// Which plan a subscription *is* comes from the product, never from the
|
|
// event. Free is a real subscription here, so it announces itself with
|
|
// the same `subscription.created` a paid one does — reading the type
|
|
// alone would put every new signup on the paid allowance.
|
|
const { productId: paidProduct, freeProductId } = settings();
|
|
const plan: Standing['plan'] | null =
|
|
productId && productId === paidProduct
|
|
? 'paid'
|
|
: productId && productId === freeProductId
|
|
? 'free'
|
|
: null;
|
|
|
|
// A product we do not recognise is left alone rather than guessed at.
|
|
// Somebody selling something else through the same account should not be
|
|
// able to change what a team may run by doing so.
|
|
if (!plan) {
|
|
return null;
|
|
}
|
|
|
|
switch (eventType) {
|
|
case 'subscription.created':
|
|
case 'subscription.active':
|
|
case 'subscription.updated':
|
|
case 'subscription.uncanceled':
|
|
return { plan, status: 'active' };
|
|
case 'subscription.canceled':
|
|
return { plan, status: 'canceled' };
|
|
case 'subscription.past_due':
|
|
return { plan, status: 'past_due' };
|
|
case 'subscription.revoked':
|
|
// Whatever it was, it is over. Free is where everybody lands.
|
|
return { plan: 'free', status: 'revoked' };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface Delivery {
|
|
type: string;
|
|
/** The team this is about, from the customer's external id. */
|
|
teamId: string | null;
|
|
standing: Standing | null;
|
|
}
|
|
|
|
/**
|
|
* Check a webhook is really from them, and say what it means.
|
|
*
|
|
* The signature is checked over the **raw body**, before anything is parsed:
|
|
* this is the one route in the API that no session protects, so the
|
|
* signature is the whole of its authentication, and a body that has been
|
|
* through `JSON.parse` and back is not the body that was signed.
|
|
*
|
|
* A bad signature is an authentication failure and not a server fault — it
|
|
* is what an attacker gets, and it must read the same as a stale secret so
|
|
* that neither tells anybody which it was.
|
|
*/
|
|
export const receive = fn(
|
|
z.object({ body: z.string(), headers: z.record(z.string(), z.string()) }),
|
|
(input): Delivery => {
|
|
const { webhookSecret } = settings();
|
|
if (!webhookSecret) {
|
|
throw new VisibleError(
|
|
'internal',
|
|
ErrorCodes.Server.DEPENDENCY_FAILURE,
|
|
'Billing is not configured'
|
|
);
|
|
}
|
|
|
|
// Two signing schemes, told apart by the secret itself.
|
|
//
|
|
// A `whsec_` prefix means Standard Webhooks, where the secret is a
|
|
// base64 key the library decodes. Anything else is the older scheme,
|
|
// where the secret is used as its own raw bytes. The SDK's helper
|
|
// only implements the older one — it base64-encodes whatever it is
|
|
// handed, which turns a Standard Webhooks secret into the literal
|
|
// bytes of the string including the prefix, and then every signature
|
|
// fails to match for a reason no error message mentions.
|
|
//
|
|
// Reading the prefix rather than configuring which scheme is in use
|
|
// means rotating a secret cannot put the two out of step.
|
|
let event: { type: string; data?: Record<string, unknown> };
|
|
try {
|
|
if (webhookSecret.startsWith('whsec_')) {
|
|
const verified = new StandardWebhook(webhookSecret).verify(input.body, input.headers);
|
|
event = verified as { type: string; data?: Record<string, unknown> };
|
|
} else {
|
|
event = validateEvent(input.body, input.headers, webhookSecret) as {
|
|
type: string;
|
|
data?: Record<string, unknown>;
|
|
};
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof WebhookVerificationError || error instanceof Error) {
|
|
throw new VisibleError(
|
|
'authentication',
|
|
ErrorCodes.Authentication.INVALID_TOKEN,
|
|
'Signature does not match'
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// Both spellings, for both paths. The SDK's parser renames fields to
|
|
// camelCase on the way through; verifying the signature ourselves
|
|
// hands back exactly what was sent, which is snake_case. Reading only
|
|
// one spelling makes every delivery arrive intact, verify correctly,
|
|
// and then quietly apply to nobody.
|
|
const data = event.data ?? {};
|
|
const customer = data.customer as
|
|
| { externalId?: string | null; external_id?: string | null }
|
|
| undefined;
|
|
// `externalId` is the team id we put on the customer. A delivery
|
|
// without one is about a customer created some other way — by hand in
|
|
// their dashboard, most likely — and there is nothing here it can
|
|
// change.
|
|
const teamId = customer?.externalId ?? customer?.external_id ?? null;
|
|
|
|
const product = data.product as { id?: string } | undefined;
|
|
const productId =
|
|
(data.productId as string | undefined) ??
|
|
(data.product_id as string | undefined) ??
|
|
product?.id ??
|
|
null;
|
|
|
|
return { type: event.type, teamId, standing: standingFor(event.type, productId) };
|
|
}
|
|
);
|
|
}
|