diff --git a/README.md b/README.md index a5d51ab..8ad297f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/porkbun.ts b/bin/porkbun.ts index ad983cc..33d0d07 100755 --- a/bin/porkbun.ts +++ b/bin/porkbun.ts @@ -28,6 +28,7 @@ import { deleteForward, deleteRecord, formatForwards, + formatPrice, formatRecords, fqdn, hostLabel, @@ -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 { @@ -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')) { diff --git a/src/porkbun.ts b/src/porkbun.ts index 4597c0e..4e948d9 100644 --- a/src/porkbun.ts +++ b/src/porkbun.ts @@ -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; } /** @@ -704,13 +709,21 @@ export async function previewRegistration( ): Promise { 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), }; } diff --git a/test/porkbun.test.ts b/test/porkbun.test.ts index 675e3e5..79e7b23 100644 --- a/test/porkbun.test.ts +++ b/test/porkbun.test.ts @@ -715,9 +715,10 @@ 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.', }, }); @@ -725,14 +726,28 @@ describe('previewRegistration', () => { 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 }, @@ -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(); }); });