Skip to content
Merged
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: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -540,8 +540,15 @@ it is the same number. `--dry-run` prices it and stops, `--max-price` refuses
anything dearer (a premium name can be hundreds), and a promotional first year is
called out because it is not what you will pay next year. WHOIS privacy is on
unless you pass `--no-whois-privacy`, and a TLD that cannot do privacy is refused
rather than quietly publishing your address. It pays from the account balance,
topping up the card on file if that is short.
rather than quietly publishing your address.

**It spends prepaid Porkbun credit, and there is no card behind it.** A zero
balance cannot register anything, however valid the request, so `register` stops
on short funds and says how much is missing rather than reporting a vague
refusal. Porkbun gates API registration behind three further account facts, none
of which any read endpoint exposes: a verified email *and* phone, at least one
registration placed previously, and the name not being premium — premium is
website-only at any price.

Before charging anything it runs Porkbun's **own** pre-flight (`dryRun`), which is
the only way to see the account-level gates — funds, the monthly API spend cap and
Expand Down
36 changes: 28 additions & 8 deletions bin/porkbun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
deleteForward,
deleteRecord,
formatForwards,
formatPrice,
formatRecords,
fqdn,
hostLabel,
Expand Down Expand Up @@ -92,9 +93,13 @@ Credentials come from PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY, via
to be switched on per domain, in the domain's settings — a key that pings fine
still gets "Invalid domain" until that is on.

\`register\` pays from the Porkbun account balance, topping up the card on file
if it is short. It registers for the TLD's minimum term with auto-renew on and
WHOIS privacy on, using the account's default contacts.
\`register\` spends **prepaid account credit** — it does not charge a card. An
account with a zero balance cannot register anything, however valid the request;
add credit at porkbun.com first. Porkbun also requires the account's email and
phone to be verified, and at least one registration placed previously, before it
will sell through the API at all. It registers for the TLD's minimum term with
auto-renew on and WHOIS privacy on, using the account's default contacts.
Premium names cannot be bought through the API at any price.
`;

function fail(message: string, code = 2): never {
Expand Down Expand Up @@ -370,15 +375,30 @@ if (isMain(import.meta.url)) {
// cap and account verification are account-level gates that no read
// endpoint reports, so this is the only way to see them before paying.
const preview = await previewRegistration(call, plan);
process.stderr.write(
` balance: ${preview.balance ?? 'unknown'}` +
`${preview.sufficientFunds === false ? ' — will top up the card on file' : ''}\n`,
);
process.stderr.write(` credit: ${preview.balance ?? 'unknown'}\n`);

if (preview.withinMonthlySpendLimit === false) {
throw new PorkbunError(`${plan.domain} would exceed the account's monthly API spend cap`);
}
// Registration spends prepaid credit; there is no card to fall back on,
// so short funds is a stop rather than a note. Say the shortfall, since
// "insufficient funds" without a number means another round trip.
if (preview.sufficientFunds === false) {
const short =
preview.balanceCents === null
? ''
: ` — ${formatPrice((plan.costCents - preview.balanceCents) / 100)} short`;
throw new PorkbunError(
`not enough Porkbun credit to register ${plan.domain}${short}.\n` +
' Registration spends prepaid credit, not a card. Add credit at ' +
'https://porkbun.com/account/billing',
);
}
if (!preview.wouldSucceed) {
throw new PorkbunError(`Porkbun refused the pre-flight for ${plan.domain}`);
throw new PorkbunError(
`Porkbun refused the pre-flight for ${plan.domain}` +
`${preview.message ? `: ${preview.message}` : ''}`,
);
}

if (parsed.flags.has('--dry-run')) {
Expand Down
17 changes: 15 additions & 2 deletions src/porkbun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,10 +681,15 @@ export interface RegistrationPreview {
wouldSucceed: boolean;
/** Porkbun's own rendering of the charge, e.g. `$11.08`. */
costDisplay: string | null;
/** Remaining account credit, in whole cents. `balance` is pennies, not dollars. */
balanceCents: number | null;
/** The same, rendered: `$0.00`. */
balance: string | null;
sufficientFunds: boolean | null;
/** Null when no monthly API spend cap is set on the account. */
withinMonthlySpendLimit: boolean | null;
/** Porkbun's own sentence about the outcome, worth printing verbatim. */
message: string | null;
}

/**
Expand All @@ -704,13 +709,21 @@ export async function previewRegistration(
): Promise<RegistrationPreview> {
const body = await call(`/domain/create/${plan.domain}`, { ...createBody(plan), dryRun: true });
const bool = (value: unknown): boolean | null => (typeof value === 'boolean' ? value : null);
const text = (value: unknown): string | null => (typeof value === 'string' ? value : null);

// `balance` is pennies as a NUMBER (5000 meaning $50.00), not a rendered
// string. Reading it as a string yields null for every account, which is
// exactly the sort of nothing that looks like "no data" rather than a bug.
const balanceCents = typeof body.balance === 'number' ? body.balance : null;

return {
wouldSucceed: body.wouldSucceed === true,
costDisplay: typeof body.costDisplay === 'string' ? body.costDisplay : null,
balance: typeof body.balance === 'string' ? body.balance : null,
costDisplay: text(body.costDisplay),
balanceCents,
balance: balanceCents === null ? null : formatPrice(balanceCents / 100),
sufficientFunds: bool(body.sufficientFunds),
withinMonthlySpendLimit: bool(body.withinMonthlySpendLimit),
message: text(body.message),
};
}

Expand Down
25 changes: 21 additions & 4 deletions test/porkbun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,24 +715,39 @@ describe('previewRegistration', () => {
wouldSucceed: true,
cost: 1108,
costDisplay: '$11.08',
balance: '$0.00',
sufficientFunds: false,
balance: 5000,
sufficientFunds: true,
withinMonthlySpendLimit: true,
message: 'Dry run: this registration would succeed and cost $11.08.',
},
});

const preview = await previewRegistration(call, samplePlan());
expect(preview).toEqual({
wouldSucceed: true,
costDisplay: '$11.08',
balance: '$0.00',
sufficientFunds: false,
balanceCents: 5000,
balance: '$50.00',
sufficientFunds: true,
withinMonthlySpendLimit: true,
message: 'Dry run: this registration would succeed and cost $11.08.',
});
// The flag that makes it free to run.
expect(seen[0]?.body.dryRun).toBe(true);
});

// `balance` is pennies as a number, not a rendered string. Reading it as a
// string gave null for every account — a bug that looks like missing data.
it('reads balance as pennies rather than a dollar string', async () => {
const { call } = scripted({
'/domain/create/diskpush.com': { status: 'SUCCESS', wouldSucceed: true, balance: 0 },
});
const preview = await previewRegistration(call, samplePlan());

expect(preview.balanceCents).toBe(0);
expect(preview.balance).toBe('$0.00');
});

it('sends the same cost and terms the real call would', async () => {
const { call, seen } = scripted({
'/domain/create/diskpush.com': { status: 'SUCCESS', wouldSucceed: true },
Expand All @@ -749,5 +764,7 @@ describe('previewRegistration', () => {
const preview = await previewRegistration(call, samplePlan());
expect(preview.withinMonthlySpendLimit).toBeNull();
expect(preview.sufficientFunds).toBeNull();
expect(preview.balanceCents).toBeNull();
expect(preview.balance).toBeNull();
});
});
Loading