diff --git a/README.md b/README.md index 2792e83..9249c1c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ This repository is the **GitHub Action MVP** (PRD Phase 1). It runs on `issue_co | Command | Who | Description | | --- | --- | --- | +| `/coinpay create @payer ""` | Anyone (opt-in, disabled by default) | Create **and publish** a CoinPayPortal invoice from the repository's configured business and reply with a live payment link. Add `--dry-run` to preview without creating anything. See [GitHub-published invoices](#github-published-invoices-coinpay-create-payer). | | `/coinpay create $10 USD --wallet
` | Maintainer on a PR | Create an idempotent payment from the PR and up to five linked closing issues. Add `--dry-run` to preview without creating anything. | | `/coinpay invoice USD --crypto --for ""` | Maintainer (direct) / contributor (request) | Create or request a payment. | | `/coinpay approve` | Maintainer | Approve the pending request in this thread. | @@ -29,9 +30,42 @@ This repository is the **GitHub Action MVP** (PRD Phase 1). It runs on `issue_co | `/coinpay status` | Anyone | Payment status (pull-only in Action mode). | | `/coinpay help` | Anyone | Show help. | +The first argument after `create` selects the flow: `@payer` runs the invoice flow below, while a numeric amount keeps the legacy PR-backed payment flow exactly as before (including its maintainer gate and `--wallet` requirement). Neither flow falls back to the other. + `` is a CoinPayPortal crypto code: `usdc_pol`, `usdc_sol`, `usdc_base`, `usdt_pol`, `btc`, `eth`, `sol`, … Amounts are **USD-denominated decimal values** with up to two fractional digits and up to nine digits before the decimal point. The PR-backed `create` command requires an explicit maintainer-supplied wallet and derives its description and canonical links from GitHub rather than accepting free-form invoice text. -Direct-create vs. request is decided by the commenter's `author_association`: `OWNER`/`MEMBER`/`COLLABORATOR` create directly; everyone else creates a pending request a maintainer approves. Tune with [`.github/coinpay.yml`](examples/coinpay.yml). +Direct-create vs. request is decided by the commenter's `author_association`: `OWNER`/`MEMBER`/`COLLABORATOR` create directly; everyone else creates a pending request a maintainer approves. Tune with [`.github/coinpay.yml`](examples/coinpay.yml). The `minRoleToCreateInvoice` gate applies to the legacy payment flow only — the `@payer` invoice flow has its own safeguards below. + +## GitHub-published invoices (`/coinpay create @payer …`) + +``` +/coinpay create @octocat 25 "Fix the settlement race" +/coinpay create @octocat $25 USD "Fix the settlement race" --dry-run +``` + +Creates a **draft invoice** on the repository's configured CoinPayPortal business via the idempotent `POST /api/invoices` contract, **publishes** it (live payment details, **no email is sent**), and replies with the payer mention, invoice number, USD amount, description, the platform fee taken from the API response, and the live `…/now/{invoice}` payment link. Payment happens inside CoinPayPortal so the platform fee applies; the bot never moves funds, never marks anything paid, and never calls send/paid/delete APIs. + +**Honest limitation — who issues, who pays.** The invoice issuer is always the **repository's configured CoinPayPortal business** (`COINPAY_BUSINESS_ID` + `COINPAY_API_KEY` from repository secrets). It is *not* the commenting user's personal CoinPay account: no GitHub-to-CoinPay account mapping exists yet. Likewise `@payer` is only a GitHub mention used for notification and audit — it is not a verified CoinPay client, and no client record is created. Every bot reply states this. Personal account linking is a later, separate milestone. + +**Who may run it.** Once enabled, any human commenter — deliberately no role allowlist. Bot-authored and edited comments are ignored. The mandatory safeguards are non-identity ones: + +- **Feature flag, default off** (`githubInvoices.enabled`). Roll out in this order: **1)** deploy the CoinPayPortal idempotent invoice creation API **and its database migration**, **2)** verify a test call succeeds, **3)** only then set `githubInvoices.enabled: true`. Until then the command replies with a safe explanation (and the API would answer 503 anyway). The global `enabled: false` kill switch also covers this command. +- **Per-invoice cap** `githubInvoices.maxAmountUsd` (default 1000). +- **Per-repository hourly cap** `githubInvoices.repositoryHourlyCap` (default 20, API range 1–1000), enforced **atomically by CoinPayPortal** per business/repository, so parallel workflow runs cannot overshoot it. Idempotent replays don't consume the cap. +- **One invoice per source comment.** The `Idempotency-Key` uses the immutable repository ID + comment ID, so redeliveries, reruns, and process restarts return the *original* invoice instead of a new one; the same key with different terms is rejected (409). A repository rename can change the source notes and cause a 409, but cannot create another invoice under a new key. A deleted invoice is never recreated (410) and a closed (e.g. already paid) invoice is reported but never reopened or republished. +- **Strict parsing**: one valid GitHub `@login`, a positive USD amount with at most two decimals, a quoted plain-text description (≤200 chars, control characters stripped), optional literal `USD`, and `--dry-run` as the only flag. No wallet, client, email, or any other flag can be injected; the business's configured receiving wallet is always the payee. +- **Audit source data**: the immutable numeric GitHub actor id, actor login, payer login, repository, thread and comment id are recorded on the invoice (`source_reference`), and the notes carry the canonical thread URL. + +If draft creation succeeds but publish fails (including a `409` while payment details are still being generated), the bot replies with a safe retry message and **posts no payment link**. A maintainer must **re-run the same GitHub Actions run**, preserving its original comment ID; posting a new command comment would request a separate invoice. Each invoice request has a 30-second network timeout; a lost response may mean creation already succeeded, so the same-run retry is important. Replies never contain raw API errors, keys, or wallet addresses. Duplicate *GitHub comments* are best-effort deduplicated (hidden `coinpay:handled` markers plus a post-publish re-check) — GitHub offers no atomic lock, so the hard uniqueness guarantee is on the invoice itself, not the reply. + +`--dry-run` previews the exact invoice (amount, description, crypto, idempotency key) without calling CoinPayPortal, changing labels, or notifying the payer (the mention is rendered inert). + +### Deployment checks before enabling + +- Set the Action's `coinpay-base-url` to the same public origin as the portal's `NEXT_PUBLIC_APP_URL` (ignoring a trailing slash). The bot rejects unexpected payment-link origins instead of posting them. +- Explicitly accept that any human commenter can create invoices under the configured business and notify a payer. No funds move automatically, but open usage can generate unwanted invoices and mentions. +- The invoice publish path does not use the legacy payment-creation route's monthly quota check. The hourly cap is a repository integration safeguard, not a subscription entitlement or a substitute for platform-wide abuse controls. Keep the feature disabled until the business owner accepts that rollout boundary. +- A rejected request needs its terms/configuration checked before retrying. Do not repeatedly re-run a permanent validation failure or post replacement commands without checking whether an invoice already exists. ## How it works @@ -41,8 +75,9 @@ If `github-token` is a PAT or another token that posts as a different login, set ## Limitations (Action MVP) -- **A receiving wallet (or `--wallet
`) is required.** Crypto payment creation returns an error otherwise. -- **No live webhook status sync.** A GitHub Action is ephemeral and cannot receive CoinPayPortal webhooks, so `coinpay:paid` / `coinpay:expired` labels and paid-status comments land with the hosted GitHub App (Phase 2), not here. +- **A receiving wallet (or `--wallet
`) is required.** Crypto payment creation returns an error otherwise. The `@payer` invoice flow uses only the business's configured wallet and fails safely when none exists. +- **No live webhook status sync.** A GitHub Action is ephemeral and cannot receive CoinPayPortal webhooks, so `coinpay:paid` / `coinpay:expired` labels and paid-status comments land with the hosted GitHub App (Phase 2), not here. In particular, `/coinpay status` never reports an invoice as paid just because it was created. +- **No personal CoinPay accounts.** GitHub-published invoices are issued by the repository's configured business; commenter/payer account linking, client records, status webhooks, PDF output, refunds/cancellations, and multi-business routing are out of scope for this slice. - Card / `both` payment methods require Stripe Connect on the CoinPayPortal business. ## Development diff --git a/action.yml b/action.yml index 34c9765..50ab5b6 100644 --- a/action.yml +++ b/action.yml @@ -25,9 +25,11 @@ inputs: default: 'github-actions[bot]' outputs: action: - description: 'What the bot did (invoice_created, request_pending, help, error, ...).' + description: 'What the bot did (invoice_created, invoice_published, request_pending, help, error, ...).' payment_id: description: 'CoinPayPortal payment id, when a payment was created.' + invoice_id: + description: 'CoinPayPortal invoice id, when a GitHub-published invoice was created.' runs: using: 'node24' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index 5dea4b8..e37fc21 100644 --- a/dist/index.js +++ b/dist/index.js @@ -31240,6 +31240,76 @@ function classify(status, body) { } return new CoinPayError("BAD_REQUEST", msg, status); } +function classifyInvoice(status, body) { + const msg = body?.error ?? `HTTP ${status}`; + const code = body?.code; + if (status === 401 || status === 403) return new CoinPayError("AUTH", msg, status); + if (status === 409) { + return code === "IDEMPOTENCY_CONFLICT" ? new CoinPayError("IDEMPOTENCY_CONFLICT", msg, status) : new CoinPayError("PUBLISH_RETRY", msg, status); + } + if (status === 410) return new CoinPayError("INVOICE_DELETED", msg, status); + if (status === 429) return new CoinPayError("RATE_LIMIT", msg, status); + if (status === 503) return new CoinPayError("UNAVAILABLE", msg, status); + if (status >= 500) return new CoinPayError("SERVER", msg, status); + if (status === 400) { + if (code === "INVOICE_NOT_PUBLISHABLE" || code === "INVOICE_NOT_ACTIVATABLE") { + return new CoinPayError("NOT_PUBLISHABLE", msg, status); + } + if (code === "PAYEE_REQUIRED" || code === "PAYEE_INVALID" || code === "CRYPTO_REQUIRED" || /no .*wallet configured/i.test(msg)) { + return new CoinPayError("NO_WALLET", msg, status); + } + return new CoinPayError("BAD_REQUEST", msg, status); + } + return new CoinPayError("BAD_REQUEST", msg, status); +} +var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +function invalidResponse(detail) { + return new CoinPayError("INVALID_RESPONSE", `Malformed CoinPayPortal invoice response: ${detail}`, 200); +} +function usdCents(value) { + const n = decimalNumber(value); + const cents = Math.round(n * 100); + return Number.isSafeInteger(cents) && n === cents / 100 ? cents : Number.NaN; +} +function decimalNumber(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : Number.NaN; + if (typeof value !== "string" || !/^\d+(?:\.\d+)?$/.test(value)) return Number.NaN; + const n = Number(value); + return Number.isFinite(n) ? n : Number.NaN; +} +function parseInvoiceSummary(invoice, expected) { + if (!invoice || typeof invoice !== "object") throw invalidResponse("missing invoice"); + const row = invoice; + if (typeof row["id"] !== "string" || !UUID_RE.test(row["id"])) { + throw invalidResponse("invoice id is not a UUID"); + } + if (row["business_id"] !== expected.businessId) { + throw invalidResponse("invoice belongs to a different business"); + } + if (row["currency"] !== "USD") throw invalidResponse("invoice currency is not USD"); + if (usdCents(row["amount"]) !== usdCents(expected.amountUsd)) { + throw invalidResponse("invoice amount differs from the requested amount"); + } + if (typeof row["invoice_number"] !== "string" || row["invoice_number"].trim() === "") { + throw invalidResponse("invoice number missing"); + } + if (typeof row["status"] !== "string" || row["status"] === "") { + throw invalidResponse("invoice status missing"); + } + let feeRate = null; + if (row["fee_rate"] !== null && row["fee_rate"] !== void 0) { + feeRate = decimalNumber(row["fee_rate"]); + if (!Number.isFinite(feeRate) || feeRate < 0 || feeRate > 1) throw invalidResponse("invalid fee rate"); + } + return { + invoiceId: row["id"], + invoiceNumber: row["invoice_number"], + status: row["status"], + amountUsd: expected.amountUsd, + currency: "USD", + feeRate + }; +} var CoinPayClient = class { baseUrl; apiKey; @@ -31285,6 +31355,93 @@ var CoinPayClient = class { raw: json.payment }; } + /** Hosted invoice checkout link. Verified path shape: `/now/{invoice_id}`. */ + invoiceLink(invoiceId) { + return `${this.baseUrl}/now/${invoiceId}`; + } + /** + * Create a draft invoice via the idempotent `POST /api/invoices` contract. + * The business's configured payee is used — this request never names a + * wallet, client, or email. A replay of the same key returns the original + * invoice (200) whatever its current status; changed terms are a 409. + */ + async createInvoice(input) { + const body = { + business_id: this.businessId, + amount: input.amountUsd, + currency: "USD", + // Invoice publish passes this directly to the payment service's + // uppercase Blockchain enum; the legacy payments API normalizes itself. + crypto_currency: input.cryptoCurrency.toUpperCase(), + notes: input.notes, + source_reference: { + provider: "github", + repository: input.source.repository, + thread_number: input.source.threadNumber, + comment_id: input.source.commentId, + actor_id: input.source.actorId, + actor_login: input.source.actorLogin, + payer_login: input.source.payerLogin + }, + source_rate_limit: input.sourceRateLimit + }; + const res = await this.call("POST", "/api/invoices", body, { + "Idempotency-Key": input.idempotencyKey + }, true); + const json = await res.json().catch(() => null); + if (!res.ok) throw classifyInvoice(res.status, json); + if (json?.success !== true || typeof json.idempotentReplay !== "boolean") { + throw invalidResponse("missing success/idempotentReplay"); + } + const summary = parseInvoiceSummary(json.invoice, { + businessId: this.businessId, + amountUsd: input.amountUsd + }); + return { ...summary, idempotentReplay: json.idempotentReplay }; + } + /** + * Publish a draft/sent invoice via `POST /api/invoices/{id}/publish` — + * creates live payment details WITHOUT emailing anyone. The returned row is + * verified (sent + payment address + our business/amount) before the caller + * may post a link, and the link itself is derived from the configured base + * URL rather than trusted from the response body. + */ + async publishInvoice(invoiceId, expected) { + if (!UUID_RE.test(invoiceId)) throw invalidResponse("invoice id is not a UUID"); + const res = await this.call("POST", `/api/invoices/${invoiceId}/publish`, void 0, void 0, true); + const json = await res.json().catch(() => null); + if (!res.ok) throw classifyInvoice(res.status, json); + if (json?.success !== true || typeof json.idempotentReplay !== "boolean") throw invalidResponse("missing success/idempotentReplay"); + if (json.emailAttempted !== false) { + throw invalidResponse("publish endpoint reported an email attempt"); + } + const summary = parseInvoiceSummary(json.invoice, { + businessId: this.businessId, + amountUsd: expected.amountUsd + }); + if (summary.invoiceId !== invoiceId) throw invalidResponse("published a different invoice"); + if (summary.status !== "sent") throw invalidResponse(`status is ${summary.status}, not sent`); + if (summary.feeRate === null) throw invalidResponse("fee rate missing after publish"); + const row = json.invoice; + if (typeof row["payment_address"] !== "string" || row["payment_address"].trim() === "") { + throw invalidResponse("payment address missing"); + } + if (json.paymentLink !== this.invoiceLink(invoiceId)) { + throw invalidResponse("payment link does not match the invoice"); + } + const feeAmountUsd = row["fee_amount"] !== null && row["fee_amount"] !== void 0 ? decimalNumber(row["fee_amount"]) : expected.amountUsd * summary.feeRate; + if (!Number.isFinite(feeAmountUsd) || feeAmountUsd < 0 || feeAmountUsd > expected.amountUsd) { + throw invalidResponse("invalid fee amount"); + } + return { + ...summary, + feeRate: summary.feeRate, + feeAmountUsd, + paymentAddress: row["payment_address"], + paymentLink: this.invoiceLink(invoiceId), + idempotentReplay: json.idempotentReplay === true + }; + } /** Fetch current payment state (drives pull-only `/coinpay status`). */ async getPayment(paymentId) { const res = await this.call("GET", `/api/payments/${encodeURIComponent(paymentId)}`); @@ -31300,10 +31457,11 @@ var CoinPayClient = class { if (json === null) throw new CoinPayError("SERVER", "Empty response body", res.status); return json; } - async call(method, path, body, extraHeaders) { + async call(method, path, body, extraHeaders, invoiceRequest = false) { try { return await this.fetchImpl(`${this.baseUrl}${path}`, { method, + ...invoiceRequest ? { signal: AbortSignal.timeout(3e4) } : {}, headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, @@ -31451,15 +31609,20 @@ var SUPPORTED_CRYPTO = /* @__PURE__ */ new Set([ "usdc_base" ]); var USD_AMOUNT_RE = /^\d{1,9}(?:\.\d{1,2})?$/; +var GITHUB_LOGIN_RE = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; +function isValidGithubLogin(value) { + return GITHUB_LOGIN_RE.test(value); +} +var MAX_INVOICE_DESCRIPTION_LENGTH = 200; function isCanonicalUsdAmount(value) { return typeof value === "number" && Number.isFinite(value) && value > 0 && USD_AMOUNT_RE.test(String(value)); } -function tokenize(line) { +function tokenizeDetailed(line) { const tokens = []; const re = /"([^"]*)"|'([^']*)'|(\S+)/g; let m; while ((m = re.exec(line)) !== null) { - tokens.push(m[1] ?? m[2] ?? m[3] ?? ""); + tokens.push({ text: m[1] ?? m[2] ?? m[3] ?? "", quoted: m[3] === void 0 }); } return tokens; } @@ -31498,7 +31661,8 @@ function parseCommand(body) { if (line === null) { return { kind: "error", code: "not_a_command", message: "No /coinpay command found." }; } - const tokens = tokenize(line); + const detailed = tokenizeDetailed(line); + const tokens = detailed.map((token) => token.text); const sub = (tokens[1] ?? "help").toLowerCase(); switch (sub) { case "help": @@ -31510,6 +31674,9 @@ function parseCommand(body) { case "cancel": return { kind: "cancel" }; case "create": + if (detailed[2]?.text.startsWith("@")) { + return parsePublishInvoice(detailed.slice(2)); + } return parseInvoice(tokens.slice(2), "create"); case "invoice": return parseInvoice(tokens.slice(2), "invoice"); @@ -31521,6 +31688,87 @@ function parseCommand(body) { }; } } +var PUBLISH_INVOICE_USAGE = 'Example: `/coinpay create @payer 25 "Fix the settlement race"`'; +function sanitizeDescription(value) { + return value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim(); +} +function parsePublishInvoice(args) { + const fail = (code, message) => ({ + kind: "error", + code, + message, + flow: "publish_invoice" + }); + let dryRun = false; + const positionals = []; + for (const token of args) { + if (!token.quoted && token.text.startsWith("--")) { + if (token.text === "--dry-run") { + dryRun = true; + continue; + } + return fail( + "unknown_flag", + `Unsupported flag for \`/coinpay create @payer\`. Only \`--dry-run\` is supported. ${PUBLISH_INVOICE_USAGE}` + ); + } + positionals.push(token); + } + const payer = positionals[0].text.slice(1); + if (!GITHUB_LOGIN_RE.test(payer)) { + return fail( + "bad_payer", + `The payer must be a single valid GitHub login mention. ${PUBLISH_INVOICE_USAGE}` + ); + } + const amountToken = positionals[1]; + if (amountToken === void 0) { + return fail("missing_amount", `Missing amount. ${PUBLISH_INVOICE_USAGE}`); + } + const amountText = amountToken.text.startsWith("$") ? amountToken.text.slice(1) : amountToken.text; + const amount = Number(amountText); + if (!USD_AMOUNT_RE.test(amountText) || !Number.isFinite(amount) || amount <= 0) { + return fail( + "bad_amount", + `Invalid amount. Use a positive decimal USD amount with at most two decimal places. ${PUBLISH_INVOICE_USAGE}` + ); + } + let next = 2; + const fiatCandidate = positionals[next]; + if (fiatCandidate !== void 0 && !fiatCandidate.quoted && /^[a-z]{3}$/i.test(fiatCandidate.text)) { + if (fiatCandidate.text.toUpperCase() !== "USD") { + return fail( + "bad_fiat", + `Unsupported fiat \`${fiatCandidate.text.toUpperCase()}\`. CoinPay GitHub invoices currently support USD only.` + ); + } + next += 1; + } + const descriptionToken = positionals[next]; + if (descriptionToken === void 0) { + return fail("missing_description", `Missing description. ${PUBLISH_INVOICE_USAGE}`); + } + if (!descriptionToken.quoted) { + return fail("bad_description", `Wrap the description in quotes. ${PUBLISH_INVOICE_USAGE}`); + } + if (positionals.length > next + 1) { + return fail( + "bad_arguments", + `Unexpected extra argument. ${PUBLISH_INVOICE_USAGE}` + ); + } + const description = sanitizeDescription(descriptionToken.text); + if (description.length === 0) { + return fail("bad_description", `The description must contain visible text. ${PUBLISH_INVOICE_USAGE}`); + } + if (description.length > MAX_INVOICE_DESCRIPTION_LENGTH) { + return fail( + "bad_description", + `The description is limited to ${MAX_INVOICE_DESCRIPTION_LENGTH} characters.` + ); + } + return { kind: "publish_invoice", payer, amount, fiat: "USD", description, dryRun }; +} function parseInvoice(args, source) { const { positionals, flags, missingValueFlags } = parseFlags( args, @@ -31614,6 +31862,11 @@ var DEFAULT_LABELS = { cancelled: "coinpay:cancelled", error: "coinpay:error" }; +var DEFAULT_GITHUB_INVOICES = { + enabled: false, + maxAmountUsd: 1e3, + repositoryHourlyCap: 20 +}; var DEFAULT_CONFIG = { enabled: true, defaultCrypto: "usdc_pol", @@ -31621,6 +31874,7 @@ var DEFAULT_CONFIG = { minRoleToCreateInvoice: "collaborator", requireApprovalForNonMaintainers: true, labels: { ...DEFAULT_LABELS }, + githubInvoices: { ...DEFAULT_GITHUB_INVOICES }, commands: { invoice: true, approve: true, status: true, cancel: true } }; function resolveDefaultCrypto(value) { @@ -31628,8 +31882,21 @@ function resolveDefaultCrypto(value) { const normalized = value.trim().toLowerCase(); return SUPPORTED_CRYPTO.has(normalized) ? normalized : DEFAULT_CONFIG.defaultCrypto; } +function resolveGithubInvoices(value) { + const raw = value && typeof value === "object" ? value : {}; + const maxAmountUsd = isCanonicalUsdAmount(raw["maxAmountUsd"]) ? raw["maxAmountUsd"] : DEFAULT_GITHUB_INVOICES.maxAmountUsd; + const cap = raw["repositoryHourlyCap"]; + const repositoryHourlyCap = typeof cap === "number" && Number.isSafeInteger(cap) && cap >= 1 && cap <= 1e3 ? cap : DEFAULT_GITHUB_INVOICES.repositoryHourlyCap; + return { enabled: raw["enabled"] === true, maxAmountUsd, repositoryHourlyCap }; +} function resolveConfig(partial) { - if (!partial) return { ...DEFAULT_CONFIG, labels: { ...DEFAULT_LABELS } }; + if (!partial) { + return { + ...DEFAULT_CONFIG, + labels: { ...DEFAULT_LABELS }, + githubInvoices: { ...DEFAULT_GITHUB_INVOICES } + }; + } return { enabled: partial.enabled ?? DEFAULT_CONFIG.enabled, defaultCrypto: resolveDefaultCrypto(partial.defaultCrypto), @@ -31637,6 +31904,7 @@ function resolveConfig(partial) { minRoleToCreateInvoice: partial.minRoleToCreateInvoice ?? DEFAULT_CONFIG.minRoleToCreateInvoice, requireApprovalForNonMaintainers: partial.requireApprovalForNonMaintainers ?? DEFAULT_CONFIG.requireApprovalForNonMaintainers, labels: { ...DEFAULT_LABELS, ...partial.labels ?? {} }, + githubInvoices: resolveGithubInvoices(partial.githubInvoices), commands: { ...DEFAULT_CONFIG.commands, ...partial.commands ?? {} } }; } @@ -31808,6 +32076,59 @@ function dryRunComment(args) { handledMarker(args.handledCommentId) ].join("\n"); } +var ISSUER_DISCLOSURE = "_Issued by this repository\u2019s configured CoinPayPortal business \u2014 not the commenter\u2019s personal account. The payer mention is a GitHub reference only, not a linked CoinPay client._"; +function fmtFee(feeRate, feeAmountUsd) { + const percent = (feeRate * 100).toFixed(feeRate * 100 % 1 === 0 ? 0 : 2); + return `${percent}% (${feeAmountUsd.toFixed(2)} USD)`; +} +function githubInvoiceSuccessComment(args) { + return [ + "### CoinPayPortal invoice published", + "", + `@${args.payer} \u2014 a CoinPayPortal invoice has been issued to this thread with you as the requested payer.`, + "", + `**Invoice:** \`${markdownCodeText(args.invoiceNumber)}\` `, + `**Amount:** ${fmtAmount(args.amount, "USD")} `, + `**Description:** ${markdownCodeSpan(args.description)} `, + `**Work:** [${markdownLinkText(args.threadLabel)}](${args.threadUrl}) `, + `**Platform fee:** ${fmtFee(args.feeRate, args.feeAmountUsd)}`, + "", + `**Pay here:** ${cleanSummaryText(args.paymentLink)}`, + "", + `_Requested by @${cleanSummaryText(args.actor)}_`, + ISSUER_DISCLOSURE, + "", + handledMarker(args.handledCommentId) + ].join("\n"); +} +function githubInvoiceDryRunComment(args) { + return [ + "### CoinPayPortal invoice preview", + "", + "**Dry run:** no invoice was created, no payment link exists, no labels were changed, and the payer was not notified. ", + // Code span keeps the mention inert so GitHub sends no notification. + `**Payer (not notified):** \`@${markdownCodeText(args.payer)}\` `, + `**Amount:** ${fmtAmount(args.amount, "USD")} `, + `**Description:** ${markdownCodeSpan(args.description)} `, + `**Crypto:** ${cleanSummaryText(args.crypto)} `, + `**Work:** [${markdownLinkText(args.threadLabel)}](${args.threadUrl}) `, + `**Idempotency key:** \`${markdownCodeText(args.idempotencyKey)}\``, + "", + "Run the same command without `--dry-run` to create and publish the invoice.", + ISSUER_DISCLOSURE, + "", + handledMarker(args.handledCommentId) + ].join("\n"); +} +function githubInvoiceExistsComment(args) { + return [ + "### CoinPayPortal invoice already exists", + "", + `Invoice \`${markdownCodeText(args.invoiceNumber)}\` was already created from this exact comment and is now \`${markdownCodeText(args.status)}\`. It was not reopened and no new payment link was issued. Post a new comment if further payment is owed.`, + "", + handledMarker(args.handledCommentId) + ].join("\n"); +} function pendingComment(args) { const r = args.request; return [ @@ -31834,7 +32155,8 @@ function helpComment(handledCommentId) { "", "| Command | Description |", "| --- | --- |", - "| `/coinpay create $10 USD --wallet
` | On a PR, create an idempotent invoice from the PR and linked issue. |", + '| `/coinpay create @payer ""` | Publish an invoice from this repository\u2019s configured CoinPayPortal business (when enabled). Anyone may run it; `@payer` is a mention, not a linked account. Add `--dry-run` to preview. |', + "| `/coinpay create $10 USD --wallet
` | On a PR, create an idempotent payment from the PR and linked issue. |", '| `/coinpay invoice USD --crypto --for ""` | Create (maintainer) or request (contributor) a payment. |', "| `/coinpay approve` | Maintainer: approve the pending request in this thread. |", "| `/coinpay status` | Show the current payment status for this thread. |", @@ -31842,6 +32164,7 @@ function helpComment(handledCommentId) { "| `/coinpay help` | Show this help. |", "", "Examples:", + '- `/coinpay create @octocat 25 "Fix the settlement race"`', "- `/coinpay create $10 USD --wallet
--dry-run`", '- `/coinpay invoice 250 USD --crypto usdc_pol --for "Milestone 1"`' ]; @@ -31878,6 +32201,9 @@ async function handleComment(evt, deps) { return { action: "noop_duplicate" }; } if (parsed.kind === "error") { + if (parsed.flow === "publish_invoice" && !isHumanActor(evt)) { + return { action: "skipped", detail: "non_human_commenter" }; + } await deps.github.createComment(evt.ref, errorComment(parsed.message, evt.commentId)); return { action: "error", detail: parsed.code }; } @@ -31887,6 +32213,8 @@ async function handleComment(evt, deps) { return { action: "help" }; case "invoice": return handleInvoice(parsed, evt, deps, existing); + case "publish_invoice": + return handlePublishInvoice(parsed, evt, deps); case "approve": return handleApprove(evt, deps, existing); case "status": @@ -31895,6 +32223,9 @@ async function handleComment(evt, deps) { return handleCancel(evt, deps, existing); } } +function isHumanActor(evt) { + return (evt.actorType ?? "").toLowerCase() === "user"; +} async function handleInvoice(cmd, evt, deps, existing) { if (!deps.config.commands.invoice) { await deps.github.createComment(evt.ref, errorComment("The `invoice` command is disabled for this repository.", evt.commentId)); @@ -32020,6 +32351,161 @@ async function handleInvoice(cmd, evt, deps, existing) { existing ); } +function githubInvoiceIdempotencyKey(repositoryId, commentId) { + return `github:repository:${repositoryId}:comment:${commentId}`; +} +function canonicalThreadUrl(ref, isPullRequest) { + return `https://github.com/${ref.owner}/${ref.repo}/${isPullRequest ? "pull" : "issues"}/${ref.issueNumber}`; +} +async function handlePublishInvoice(cmd, evt, deps) { + if (!isHumanActor(evt)) { + return { action: "skipped", detail: "non_human_commenter" }; + } + const settings = deps.config.githubInvoices; + if (!settings.enabled) { + await deps.github.createComment( + evt.ref, + errorComment( + "The `/coinpay create @payer \u2026` invoice command is not enabled for this repository. A maintainer can enable it by setting `githubInvoices.enabled: true` in `.github/coinpay.yml` \u2014 but only after the CoinPayPortal idempotent invoice deployment (API + migration) is live, or every command will fail.", + evt.commentId + ) + ); + return { action: "noop_disabled", detail: "github_invoices_disabled" }; + } + if (!Number.isSafeInteger(evt.actorId) || evt.actorId <= 0 || !isValidGithubLogin(evt.actor) || !Number.isSafeInteger(evt.repositoryId) || evt.repositoryId <= 0 || !Number.isSafeInteger(evt.commentId) || evt.commentId <= 0) { + await deps.github.createComment( + evt.ref, + errorComment( + "Could not verify the GitHub actor, repository, and comment identities, so no invoice was created.", + evt.commentId + ) + ); + return { action: "error", detail: "missing_actor_identity" }; + } + if (cmd.amount > settings.maxAmountUsd) { + await deps.github.createComment( + evt.ref, + errorComment( + `The amount ${cmd.amount.toFixed(2)} USD exceeds this repository\u2019s per-invoice maximum of ${settings.maxAmountUsd.toFixed(2)} USD (config: \`githubInvoices.maxAmountUsd\`).`, + evt.commentId + ) + ); + return { action: "error", detail: "amount_over_limit" }; + } + const threadUrl = canonicalThreadUrl(evt.ref, evt.isPullRequest); + const threadLabel = `${evt.ref.owner}/${evt.ref.repo}#${evt.ref.issueNumber}`; + const idempotencyKey = githubInvoiceIdempotencyKey(evt.repositoryId, evt.commentId); + if (cmd.dryRun) { + await deps.github.createComment( + evt.ref, + githubInvoiceDryRunComment({ + payer: cmd.payer, + amount: cmd.amount, + description: cmd.description, + crypto: deps.config.defaultCrypto, + threadUrl, + threadLabel, + idempotencyKey, + handledCommentId: evt.commentId + }) + ); + return { action: "dry_run" }; + } + try { + const created = await deps.coinpay.createInvoice({ + amountUsd: cmd.amount, + cryptoCurrency: deps.config.defaultCrypto, + // Stable notes: sanitized description + canonical thread/comment URL. + notes: `${cmd.description} + +${threadUrl}#issuecomment-${evt.commentId}`, + source: { + repository: `${evt.ref.owner}/${evt.ref.repo}`, + threadNumber: evt.ref.issueNumber, + commentId: evt.commentId, + actorId: evt.actorId, + actorLogin: evt.actor, + payerLogin: cmd.payer + }, + sourceRateLimit: settings.repositoryHourlyCap, + idempotencyKey + }); + if (created.status !== "draft" && created.status !== "sent") { + await deps.github.createComment( + evt.ref, + githubInvoiceExistsComment({ + invoiceNumber: created.invoiceNumber, + status: created.status, + handledCommentId: evt.commentId + }) + ); + return { action: "invoice_already_closed", detail: created.status, invoiceId: created.invoiceId }; + } + const published = await deps.coinpay.publishInvoice(created.invoiceId, { + amountUsd: cmd.amount + }); + const latest = await deps.github.listComments(evt.ref); + if (isHandled(latest, evt.commentId)) { + return { action: "noop_duplicate", invoiceId: created.invoiceId }; + } + await deps.github.createComment( + evt.ref, + githubInvoiceSuccessComment({ + payer: cmd.payer, + actor: evt.actor, + amount: cmd.amount, + description: cmd.description, + invoiceNumber: published.invoiceNumber, + paymentLink: published.paymentLink, + feeRate: published.feeRate, + feeAmountUsd: published.feeAmountUsd, + threadUrl, + threadLabel, + handledCommentId: evt.commentId + }) + ); + await deps.github.addLabels(evt.ref, [deps.config.labels.pending]); + return { action: "invoice_published", invoiceId: created.invoiceId }; + } catch (err) { + await deps.github.createComment(evt.ref, errorComment(friendlyInvoiceError(err))); + await deps.github.addLabels(evt.ref, [deps.config.labels.error]); + return { action: "error", detail: err instanceof CoinPayError ? err.code : "unknown" }; + } +} +function friendlyInvoiceError(err) { + const retry = "Ask a maintainer to re-run this same GitHub Actions run, not post a new command comment. Only the same source comment reuses the invoice."; + if (err instanceof CoinPayError) { + switch (err.code) { + case "NO_WALLET": + return "The repository\u2019s CoinPayPortal business has no receiving wallet for the configured crypto. A maintainer can add one in CoinPayPortal settings. " + retry; + case "AUTH": + return "CoinPayPortal rejected the API key. Check the `COINPAY_API_KEY` secret for this repository."; + case "BAD_REQUEST": + return "CoinPayPortal rejected the invoice terms or configuration. A maintainer should check the command, business settings, and existing invoice before trying again. Re-running an unchanged invalid request will not fix it. No payment link was posted."; + case "RATE_LIMIT": + return "This repository\u2019s hourly invoice cap has been reached. Wait for the window to pass. " + retry; + case "IDEMPOTENCY_CONFLICT": + return "An invoice was already recorded for this comment with different terms, so no new invoice was created. Check the existing invoice in CoinPayPortal before requesting a replacement."; + case "INVOICE_DELETED": + return "The invoice originally created from this comment was deleted in CoinPayPortal and will not be recreated automatically. Post a new comment if payment is still owed."; + case "UNAVAILABLE": + return "CoinPayPortal cannot confirm idempotent invoice creation right now (the deployment or migration may still be rolling out). " + retry; + case "PUBLISH_RETRY": + return "The invoice exists but its payment details are still being prepared. " + retry; + case "NOT_PUBLISHABLE": + return "The invoice created from this comment is already closed and stays closed. No payment link was posted."; + case "INVALID_RESPONSE": + return "CoinPayPortal returned an unexpected response, so no payment link was posted. " + retry; + case "NETWORK": + return "Could not reach CoinPayPortal; creation may have completed before the connection failed. " + retry; + case "SERVER": + return "CoinPayPortal had an internal error. " + retry; + default: + return "CoinPayPortal could not confirm invoice creation. " + retry; + } + } + return "An unexpected error occurred during invoice creation or reply delivery. " + retry; +} async function handleApprove(evt, deps, existing) { if (!canApprove(evt.authorAssociation)) { await deps.github.createComment(evt.ref, errorComment(`@${evt.actor} is not authorized to approve invoice requests.`, evt.commentId)); @@ -32202,9 +32688,12 @@ async function run() { const coinpay = new CoinPayClient({ baseUrl, apiKey, businessId }); const evt = { ref, + repositoryId: payload.repository?.id, commentId: payload.comment.id, body: payload.comment.body ?? "", actor: payload.comment.user?.login ?? "unknown", + actorId: payload.comment.user?.id, + actorType: payload.comment.user?.type, authorAssociation: payload.comment.author_association ?? "NONE", issueUrl: payload.issue.html_url ?? "", isPullRequest: payload.issue.pull_request !== void 0 @@ -32213,6 +32702,7 @@ async function run() { core.info(`coinpaybot action=${result.action}${result.detail ? ` detail=${result.detail}` : ""}`); core.setOutput("action", result.action); if (result.paymentId) core.setOutput("payment_id", result.paymentId); + if (result.invoiceId) core.setOutput("invoice_id", result.invoiceId); } run().catch((err) => { core.setFailed(err instanceof Error ? err.message : String(err)); diff --git a/examples/coinpay.yml b/examples/coinpay.yml index 34ddb5e..108fab5 100644 --- a/examples/coinpay.yml +++ b/examples/coinpay.yml @@ -18,3 +18,13 @@ commands: approve: true status: true cancel: true +# `/coinpay create @payer ""` — publish a real invoice +# from THIS REPOSITORY'S configured CoinPayPortal business. Disabled by +# default: enable it only after the CoinPayPortal idempotent invoice +# deployment (API + migration) is live, or every command fails with a +# temporary-unavailability reply. Open to all human commenters once enabled; +# the payer mention is a GitHub reference, not a linked CoinPay account. +githubInvoices: + enabled: false + maxAmountUsd: 1000 # per-invoice cap (USD, up to 2 decimals) + repositoryHourlyCap: 20 # per-repository hourly cap enforced by the API (1-1000) diff --git a/src/coinpay.ts b/src/coinpay.ts index 7e0e3bf..502450c 100644 --- a/src/coinpay.ts +++ b/src/coinpay.ts @@ -25,6 +25,24 @@ * 5xx with the same key cannot create a second payable payment. * - Webhook signature header `X-CoinPay-Signature: t=,v1=` where * hex = HMAC_SHA256(`${t}.${rawBody}`, webhook_secret), 300s tolerance. + * + * Invoice creation/publish shapes were verified against the coinpayportal + * `feat/invoice-creation-idempotency` branch (2026-09-07): + * src/app/api/invoices/route.ts, src/lib/invoices/creation.ts, + * src/app/api/invoices/[id]/publish/route.ts, src/lib/invoices/activation.ts, + * src/lib/payments/service.ts (case-sensitive uppercase Blockchain enum), + * and supabase/migrations/20260907100000_invoice_creation_idempotency.sql. + * - POST /api/invoices with an `Idempotency-Key` header returns + * { success:true, invoice:, idempotentReplay:boolean } — 201 on + * first create, 200 on replay. 409 IDEMPOTENCY_CONFLICT for the same key + * with different terms, 410 INVOICE_DELETED once the original is deleted, + * 429 SOURCE_RATE_LIMIT when the per-repository hourly cap is exhausted + * (replays bypass the cap), 503 while the idempotency migration is absent. + * - POST /api/invoices/{id}/publish returns { success:true, invoice, + * paymentLink:, emailAttempted:false, + * idempotentReplay } and can 409 (PAYMENT_CREATION_IN_PROGRESS / + * INVOICE_STATE_CHANGED) requiring a retry; only draft/sent publish, a + * closed invoice is 400 INVOICE_NOT_PUBLISHABLE and stays closed. */ import { createHmac, timingSafeEqual } from 'node:crypto'; @@ -69,7 +87,15 @@ export type CoinPayErrorCode = | 'AUTH' | 'BAD_REQUEST' | 'SERVER' - | 'NETWORK'; + | 'NETWORK' + // Invoice-flow codes, mapped from the verified /api/invoices contract: + | 'IDEMPOTENCY_CONFLICT' // 409: key reused with different terms + | 'INVOICE_DELETED' // 410: original invoice deleted; key can never recreate it + | 'RATE_LIMIT' // 429: repository hourly cap exhausted (replays bypass it) + | 'UNAVAILABLE' // 503: idempotency store unavailable (e.g. migration absent) + | 'PUBLISH_RETRY' // 409 on publish: payment still being created / state changed + | 'NOT_PUBLISHABLE' // 400 on publish: invoice is closed (paid/cancelled/...) + | 'INVALID_RESPONSE'; // 2xx body that fails contract validation export class CoinPayError extends Error { readonly code: CoinPayErrorCode; @@ -97,6 +123,160 @@ function classify(status: number, body: { error?: string; usage?: unknown } | nu return new CoinPayError('BAD_REQUEST', msg, status); } +/** + * Error mapping for the invoice create/publish endpoints, which return typed + * `code` fields. The raw `error` text is kept for Action logs only — the + * handler renders fixed friendly text, never this message. + */ +function classifyInvoice( + status: number, + body: { error?: string; code?: string } | null, +): CoinPayError { + const msg = body?.error ?? `HTTP ${status}`; + const code = body?.code; + if (status === 401 || status === 403) return new CoinPayError('AUTH', msg, status); + if (status === 409) { + // Anything other than a terms conflict (payment creation in progress, + // invoice state changed, sent-without-address) is safe to retry later. + return code === 'IDEMPOTENCY_CONFLICT' + ? new CoinPayError('IDEMPOTENCY_CONFLICT', msg, status) + : new CoinPayError('PUBLISH_RETRY', msg, status); + } + if (status === 410) return new CoinPayError('INVOICE_DELETED', msg, status); + if (status === 429) return new CoinPayError('RATE_LIMIT', msg, status); + if (status === 503) return new CoinPayError('UNAVAILABLE', msg, status); + if (status >= 500) return new CoinPayError('SERVER', msg, status); + if (status === 400) { + if (code === 'INVOICE_NOT_PUBLISHABLE' || code === 'INVOICE_NOT_ACTIVATABLE') { + return new CoinPayError('NOT_PUBLISHABLE', msg, status); + } + if ( + code === 'PAYEE_REQUIRED' || + code === 'PAYEE_INVALID' || + code === 'CRYPTO_REQUIRED' || + /no .*wallet configured/i.test(msg) + ) { + return new CoinPayError('NO_WALLET', msg, status); + } + return new CoinPayError('BAD_REQUEST', msg, status); + } + return new CoinPayError('BAD_REQUEST', msg, status); +} + +/** + * Audit trail forwarded to CoinPayPortal's `source_reference` (validated there + * with the same shapes). `actorId` is the immutable numeric GitHub user id; + * logins are display/audit data only — neither maps to a CoinPay account. + */ +export interface GithubInvoiceSource { + /** `owner/repo` */ + repository: string; + threadNumber: number; + commentId: number; + actorId: number; + actorLogin: string; + payerLogin: string; +} + +export interface CreateInvoiceInput { + amountUsd: number; + /** CoinPayPortal crypto code the invoice settles in (from repo config). */ + cryptoCurrency: string; + /** Stable plain-text notes: description + canonical GitHub thread URL. */ + notes: string; + source: GithubInvoiceSource; + /** Repository hourly cap the portal enforces atomically (1-1000). */ + sourceRateLimit: number; + /** REQUIRED. Stable identity derived from repository ID + comment ID only. */ + idempotencyKey: string; +} + +/** Fields validated out of a returned invoice row before anything is posted. */ +export interface InvoiceSummary { + invoiceId: string; + invoiceNumber: string; + status: string; + amountUsd: number; + currency: string; + /** Platform fee rate from the response (e.g. 0.01), never hardcoded. */ + feeRate: number | null; +} + +export interface CreateInvoiceResult extends InvoiceSummary { + idempotentReplay: boolean; +} + +export interface PublishInvoiceResult extends InvoiceSummary { + feeRate: number; + /** Fee in USD, from the response (fee_amount, else amount × fee_rate). */ + feeAmountUsd: number; + paymentAddress: string; + /** Canonical checkout URL derived from the configured base URL. */ + paymentLink: string; + idempotentReplay: boolean; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function invalidResponse(detail: string): CoinPayError { + return new CoinPayError('INVALID_RESPONSE', `Malformed CoinPayPortal invoice response: ${detail}`, 200); +} + +function usdCents(value: unknown): number { + const n = decimalNumber(value); + const cents = Math.round(n * 100); + return Number.isSafeInteger(cents) && n === cents / 100 ? cents : Number.NaN; +} + +function decimalNumber(value: unknown): number { + if (typeof value === 'number') return Number.isFinite(value) ? value : Number.NaN; + if (typeof value !== 'string' || !/^\d+(?:\.\d+)?$/.test(value)) return Number.NaN; + const n = Number(value); + return Number.isFinite(n) ? n : Number.NaN; +} + +/** + * Validate an invoice row returned by CoinPayPortal against what we asked for. + * Every field posted back to GitHub flows through this gate, so a malformed or + * tampered response can never place foreign ids or amounts into a comment. + */ +function parseInvoiceSummary( + invoice: unknown, + expected: { businessId: string; amountUsd: number }, +): InvoiceSummary { + if (!invoice || typeof invoice !== 'object') throw invalidResponse('missing invoice'); + const row = invoice as Record; + if (typeof row['id'] !== 'string' || !UUID_RE.test(row['id'])) { + throw invalidResponse('invoice id is not a UUID'); + } + if (row['business_id'] !== expected.businessId) { + throw invalidResponse('invoice belongs to a different business'); + } + if (row['currency'] !== 'USD') throw invalidResponse('invoice currency is not USD'); + if (usdCents(row['amount']) !== usdCents(expected.amountUsd)) { + throw invalidResponse('invoice amount differs from the requested amount'); + } + if (typeof row['invoice_number'] !== 'string' || row['invoice_number'].trim() === '') { + throw invalidResponse('invoice number missing'); + } + if (typeof row['status'] !== 'string' || row['status'] === '') { + throw invalidResponse('invoice status missing'); + } + let feeRate: number | null = null; + if (row['fee_rate'] !== null && row['fee_rate'] !== undefined) { + feeRate = decimalNumber(row['fee_rate']); + if (!Number.isFinite(feeRate) || feeRate < 0 || feeRate > 1) throw invalidResponse('invalid fee rate'); + } + return { + invoiceId: row['id'], + invoiceNumber: row['invoice_number'], + status: row['status'], + amountUsd: expected.amountUsd, + currency: 'USD', + feeRate, + }; +} + export class CoinPayClient { private readonly baseUrl: string; private readonly apiKey: string; @@ -150,6 +330,112 @@ export class CoinPayClient { }; } + /** Hosted invoice checkout link. Verified path shape: `/now/{invoice_id}`. */ + invoiceLink(invoiceId: string): string { + return `${this.baseUrl}/now/${invoiceId}`; + } + + /** + * Create a draft invoice via the idempotent `POST /api/invoices` contract. + * The business's configured payee is used — this request never names a + * wallet, client, or email. A replay of the same key returns the original + * invoice (200) whatever its current status; changed terms are a 409. + */ + async createInvoice(input: CreateInvoiceInput): Promise { + const body = { + business_id: this.businessId, + amount: input.amountUsd, + currency: 'USD', + // Invoice publish passes this directly to the payment service's + // uppercase Blockchain enum; the legacy payments API normalizes itself. + crypto_currency: input.cryptoCurrency.toUpperCase(), + notes: input.notes, + source_reference: { + provider: 'github', + repository: input.source.repository, + thread_number: input.source.threadNumber, + comment_id: input.source.commentId, + actor_id: input.source.actorId, + actor_login: input.source.actorLogin, + payer_login: input.source.payerLogin, + }, + source_rate_limit: input.sourceRateLimit, + }; + const res = await this.call('POST', '/api/invoices', body, { + 'Idempotency-Key': input.idempotencyKey, + }, true); + const json = (await res.json().catch(() => null)) as { + success?: boolean; invoice?: unknown; idempotentReplay?: boolean; + error?: string; code?: string; + } | null; + if (!res.ok) throw classifyInvoice(res.status, json); + if (json?.success !== true || typeof json.idempotentReplay !== 'boolean') { + throw invalidResponse('missing success/idempotentReplay'); + } + const summary = parseInvoiceSummary(json.invoice, { + businessId: this.businessId, + amountUsd: input.amountUsd, + }); + return { ...summary, idempotentReplay: json.idempotentReplay }; + } + + /** + * Publish a draft/sent invoice via `POST /api/invoices/{id}/publish` — + * creates live payment details WITHOUT emailing anyone. The returned row is + * verified (sent + payment address + our business/amount) before the caller + * may post a link, and the link itself is derived from the configured base + * URL rather than trusted from the response body. + */ + async publishInvoice( + invoiceId: string, + expected: { amountUsd: number }, + ): Promise { + if (!UUID_RE.test(invoiceId)) throw invalidResponse('invoice id is not a UUID'); + const res = await this.call('POST', `/api/invoices/${invoiceId}/publish`, undefined, undefined, true); + const json = (await res.json().catch(() => null)) as { + success?: boolean; invoice?: unknown; paymentLink?: unknown; + emailAttempted?: unknown; idempotentReplay?: boolean; + error?: string; code?: string; + } | null; + if (!res.ok) throw classifyInvoice(res.status, json); + if (json?.success !== true || typeof json.idempotentReplay !== 'boolean') throw invalidResponse('missing success/idempotentReplay'); + if (json.emailAttempted !== false) { + // This endpoint's contract is publish-without-email. If that ever + // changes, refuse loudly rather than silently emailing payers. + throw invalidResponse('publish endpoint reported an email attempt'); + } + const summary = parseInvoiceSummary(json.invoice, { + businessId: this.businessId, + amountUsd: expected.amountUsd, + }); + if (summary.invoiceId !== invoiceId) throw invalidResponse('published a different invoice'); + if (summary.status !== 'sent') throw invalidResponse(`status is ${summary.status}, not sent`); + if (summary.feeRate === null) throw invalidResponse('fee rate missing after publish'); + const row = json.invoice as Record; + if (typeof row['payment_address'] !== 'string' || row['payment_address'].trim() === '') { + throw invalidResponse('payment address missing'); + } + if (json.paymentLink !== this.invoiceLink(invoiceId)) { + throw invalidResponse('payment link does not match the invoice'); + } + // Activation returns amount * fee_rate without rounding to USD cents. + // Validate that fee as a decimal; principal amounts still require whole cents. + const feeAmountUsd = row['fee_amount'] !== null && row['fee_amount'] !== undefined + ? decimalNumber(row['fee_amount']) + : expected.amountUsd * summary.feeRate; + if (!Number.isFinite(feeAmountUsd) || feeAmountUsd < 0 || feeAmountUsd > expected.amountUsd) { + throw invalidResponse('invalid fee amount'); + } + return { + ...summary, + feeRate: summary.feeRate, + feeAmountUsd, + paymentAddress: row['payment_address'], + paymentLink: this.invoiceLink(invoiceId), + idempotentReplay: json.idempotentReplay === true, + }; + } + /** Fetch current payment state (drives pull-only `/coinpay status`). */ async getPayment(paymentId: string): Promise<{ status: string; raw: unknown }> { const res = await this.call('GET', `/api/payments/${encodeURIComponent(paymentId)}`); @@ -176,10 +462,12 @@ export class CoinPayClient { path: string, body?: unknown, extraHeaders?: Record, + invoiceRequest = false, ): Promise { try { return await this.fetchImpl(`${this.baseUrl}${path}`, { method, + ...(invoiceRequest ? { signal: AbortSignal.timeout(30000) } : {}), headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, diff --git a/src/config.ts b/src/config.ts index f2108f8..7f7f833 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,10 +4,24 @@ * (Org/app dashboard defaults are a hosted-App concern, not the Action MVP.) */ -import { SUPPORTED_CRYPTO } from './parser.js'; +import { isCanonicalUsdAmount, SUPPORTED_CRYPTO } from './parser.js'; export type MinRole = 'owner' | 'member' | 'collaborator'; +/** + * `/coinpay create @payer ...` — publish a real CoinPayPortal invoice from a + * comment. Ships disabled: enabling it requires the CoinPayPortal idempotent + * invoice-creation deployment (API + migration) to be live first, otherwise + * every command fails with a 503. + */ +export interface GithubInvoiceConfig { + enabled: boolean; + /** Upper bound for a single invoice in USD. */ + maxAmountUsd: number; + /** Per-repository hourly invoice cap enforced atomically by CoinPayPortal (1-1000). */ + repositoryHourlyCap: number; +} + export interface LabelConfig { requested: string; pending: string; @@ -26,6 +40,7 @@ export interface ResolvedConfig { minRoleToCreateInvoice: MinRole; requireApprovalForNonMaintainers: boolean; labels: LabelConfig; + githubInvoices: GithubInvoiceConfig; commands: { invoice: boolean; approve: boolean; @@ -44,6 +59,12 @@ export const DEFAULT_LABELS: LabelConfig = { error: 'coinpay:error', }; +export const DEFAULT_GITHUB_INVOICES: GithubInvoiceConfig = { + enabled: false, + maxAmountUsd: 1000, + repositoryHourlyCap: 20, +}; + export const DEFAULT_CONFIG: ResolvedConfig = { enabled: true, defaultCrypto: 'usdc_pol', @@ -51,6 +72,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { minRoleToCreateInvoice: 'collaborator', requireApprovalForNonMaintainers: true, labels: { ...DEFAULT_LABELS }, + githubInvoices: { ...DEFAULT_GITHUB_INVOICES }, commands: { invoice: true, approve: true, status: true, cancel: true }, }; @@ -64,9 +86,33 @@ function resolveDefaultCrypto(value: unknown): string { : DEFAULT_CONFIG.defaultCrypto; } +/** + * Money movement gates fail closed: the flag enables only on a literal boolean + * `true`, and out-of-range or non-numeric limits fall back to the defaults + * rather than widening. The cap mirrors the API's accepted range (1-1000). + */ +function resolveGithubInvoices(value: unknown): GithubInvoiceConfig { + const raw = (value && typeof value === 'object' ? value : {}) as Record; + const maxAmountUsd = isCanonicalUsdAmount(raw['maxAmountUsd']) + ? raw['maxAmountUsd'] + : DEFAULT_GITHUB_INVOICES.maxAmountUsd; + const cap = raw['repositoryHourlyCap']; + const repositoryHourlyCap = + typeof cap === 'number' && Number.isSafeInteger(cap) && cap >= 1 && cap <= 1000 + ? cap + : DEFAULT_GITHUB_INVOICES.repositoryHourlyCap; + return { enabled: raw['enabled'] === true, maxAmountUsd, repositoryHourlyCap }; +} + /** Merge a partial (e.g. parsed YAML) over the product defaults. */ export function resolveConfig(partial?: DeepPartial | null): ResolvedConfig { - if (!partial) return { ...DEFAULT_CONFIG, labels: { ...DEFAULT_LABELS } }; + if (!partial) { + return { + ...DEFAULT_CONFIG, + labels: { ...DEFAULT_LABELS }, + githubInvoices: { ...DEFAULT_GITHUB_INVOICES }, + }; + } return { enabled: partial.enabled ?? DEFAULT_CONFIG.enabled, defaultCrypto: resolveDefaultCrypto(partial.defaultCrypto), @@ -75,6 +121,7 @@ export function resolveConfig(partial?: DeepPartial | null): Res requireApprovalForNonMaintainers: partial.requireApprovalForNonMaintainers ?? DEFAULT_CONFIG.requireApprovalForNonMaintainers, labels: { ...DEFAULT_LABELS, ...(partial.labels ?? {}) }, + githubInvoices: resolveGithubInvoices(partial.githubInvoices), commands: { ...DEFAULT_CONFIG.commands, ...(partial.commands ?? {}) }, }; } diff --git a/src/handler.ts b/src/handler.ts index 6cfa088..d3147ab 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -13,8 +13,8 @@ import type { ThreadComment, } from './github.js'; import { CoinPayClient, CoinPayError } from './coinpay.js'; -import { parseCommand } from './parser.js'; -import type { InvoiceCommand } from './parser.js'; +import { isValidGithubLogin, parseCommand } from './parser.js'; +import type { InvoiceCommand, PublishInvoiceCommand } from './parser.js'; import { canCreateDirectly, canApprove, canCancel } from './permissions.js'; import type { AuthorAssociation } from './permissions.js'; import * as render from './render.js'; @@ -22,9 +22,15 @@ import type { PendingRequest } from './render.js'; export interface CommentEvent { ref: IssueRef; + /** Immutable repository id, independent of renames and transfers. */ + repositoryId?: number; commentId: number; body: string; actor: string; + /** Immutable numeric GitHub user id of the comment author (audit identity). */ + actorId?: number; + /** GitHub account type of the comment author: 'User', 'Bot', ... */ + actorType?: string; authorAssociation: AuthorAssociation; /** Canonical URL of the issue/PR, used as the payer redirect target. */ issueUrl: string; @@ -41,6 +47,8 @@ export type Action = | 'skipped' | 'help' | 'invoice_created' + | 'invoice_published' + | 'invoice_already_closed' | 'dry_run' | 'request_pending' | 'approved' @@ -54,6 +62,7 @@ export interface HandlerResult { action: Action; detail?: string; paymentId?: string; + invoiceId?: string; } export async function handleComment(evt: CommentEvent, deps: HandlerDeps): Promise { @@ -72,6 +81,11 @@ export async function handleComment(evt: CommentEvent, deps: HandlerDeps): Promi } if (parsed.kind === 'error') { + // The @payer invoice flow never replies to non-human commenters, even + // with usage errors, so misfiring integrations cannot start reply loops. + if (parsed.flow === 'publish_invoice' && !isHumanActor(evt)) { + return { action: 'skipped', detail: 'non_human_commenter' }; + } await deps.github.createComment(evt.ref, render.errorComment(parsed.message, evt.commentId)); return { action: 'error', detail: parsed.code }; } @@ -82,6 +96,8 @@ export async function handleComment(evt: CommentEvent, deps: HandlerDeps): Promi return { action: 'help' }; case 'invoice': return handleInvoice(parsed, evt, deps, existing); + case 'publish_invoice': + return handlePublishInvoice(parsed, evt, deps); case 'approve': return handleApprove(evt, deps, existing); case 'status': @@ -91,6 +107,11 @@ export async function handleComment(evt: CommentEvent, deps: HandlerDeps): Promi } } +/** GitHub `user.type` for humans is exactly 'User'; anything else fails closed. */ +function isHumanActor(evt: CommentEvent): boolean { + return (evt.actorType ?? '').toLowerCase() === 'user'; +} + async function handleInvoice( cmd: InvoiceCommand, evt: CommentEvent, @@ -233,6 +254,212 @@ async function handleInvoice( ); } +/** + * Stable invoice identity for CoinPayPortal's `Idempotency-Key`: repository ID + + * comment id ONLY. It survives process restarts and redeliveries because it is + * derived, not stored — and it deliberately excludes the terms, so a key reuse + * with different terms is rejected by the API (409) instead of silently + * creating a second invoice. + */ +export function githubInvoiceIdempotencyKey(repositoryId: number, commentId: number): string { + return `github:repository:${repositoryId}:comment:${commentId}`; +} + +function canonicalThreadUrl(ref: IssueRef, isPullRequest: boolean): string { + return `https://github.com/${ref.owner}/${ref.repo}/${isPullRequest ? 'pull' : 'issues'}/${ref.issueNumber}`; +} + +/** + * `/coinpay create @payer ""` — create and publish a + * CoinPayPortal invoice issued by the repository's configured business. + * + * Open to every human commenter by design (no role gate): the safeguards are + * non-identity ones — the feature flag, the per-invoice amount cap, the + * portal-enforced per-repository hourly cap, strict parsing, and one invoice + * per source comment via API idempotency. The commenter's own CoinPay account + * is never involved; no GitHub-to-CoinPay account mapping exists. + */ +async function handlePublishInvoice( + cmd: PublishInvoiceCommand, + evt: CommentEvent, + deps: HandlerDeps, +): Promise { + // Only human-authored, newly created comments qualify; the Action entrypoint + // already drops edited comments, and bots are dropped here without a reply. + if (!isHumanActor(evt)) { + return { action: 'skipped', detail: 'non_human_commenter' }; + } + + const settings = deps.config.githubInvoices; + if (!settings.enabled) { + await deps.github.createComment( + evt.ref, + render.errorComment( + 'The `/coinpay create @payer …` invoice command is not enabled for this repository. A maintainer can enable it by setting `githubInvoices.enabled: true` in `.github/coinpay.yml` — but only after the CoinPayPortal idempotent invoice deployment (API + migration) is live, or every command will fail.', + evt.commentId, + ), + ); + return { action: 'noop_disabled', detail: 'github_invoices_disabled' }; + } + + // The immutable numeric actor id is mandatory audit data; fail closed + // rather than record an invoice that cannot be attributed. + if (!Number.isSafeInteger(evt.actorId) || evt.actorId! <= 0 || !isValidGithubLogin(evt.actor) + || !Number.isSafeInteger(evt.repositoryId) || evt.repositoryId! <= 0 + || !Number.isSafeInteger(evt.commentId) || evt.commentId <= 0) { + await deps.github.createComment( + evt.ref, + render.errorComment( + 'Could not verify the GitHub actor, repository, and comment identities, so no invoice was created.', + evt.commentId, + ), + ); + return { action: 'error', detail: 'missing_actor_identity' }; + } + + if (cmd.amount > settings.maxAmountUsd) { + await deps.github.createComment( + evt.ref, + render.errorComment( + `The amount ${cmd.amount.toFixed(2)} USD exceeds this repository’s per-invoice maximum of ${settings.maxAmountUsd.toFixed(2)} USD (config: \`githubInvoices.maxAmountUsd\`).`, + evt.commentId, + ), + ); + return { action: 'error', detail: 'amount_over_limit' }; + } + + const threadUrl = canonicalThreadUrl(evt.ref, evt.isPullRequest); + const threadLabel = `${evt.ref.owner}/${evt.ref.repo}#${evt.ref.issueNumber}`; + const idempotencyKey = githubInvoiceIdempotencyKey(evt.repositoryId!, evt.commentId); + + if (cmd.dryRun) { + await deps.github.createComment( + evt.ref, + render.githubInvoiceDryRunComment({ + payer: cmd.payer, + amount: cmd.amount, + description: cmd.description, + crypto: deps.config.defaultCrypto, + threadUrl, + threadLabel, + idempotencyKey, + handledCommentId: evt.commentId, + }), + ); + return { action: 'dry_run' }; + } + + try { + const created = await deps.coinpay.createInvoice({ + amountUsd: cmd.amount, + cryptoCurrency: deps.config.defaultCrypto, + // Stable notes: sanitized description + canonical thread/comment URL. + notes: `${cmd.description}\n\n${threadUrl}#issuecomment-${evt.commentId}`, + source: { + repository: `${evt.ref.owner}/${evt.ref.repo}`, + threadNumber: evt.ref.issueNumber, + commentId: evt.commentId, + actorId: evt.actorId!, + actorLogin: evt.actor, + payerLogin: cmd.payer, + }, + sourceRateLimit: settings.repositoryHourlyCap, + idempotencyKey, + }); + + // A replayed invoice may have closed since (paid/cancelled/...): report + // it, never republish it, and never post a payment link for it. + if (created.status !== 'draft' && created.status !== 'sent') { + await deps.github.createComment( + evt.ref, + render.githubInvoiceExistsComment({ + invoiceNumber: created.invoiceNumber, + status: created.status, + handledCommentId: evt.commentId, + }), + ); + return { action: 'invoice_already_closed', detail: created.status, invoiceId: created.invoiceId }; + } + + // Publish is idempotent for draft and sent; the adapter refuses to return + // until the row is verified sent with a payment address for OUR business. + const published = await deps.coinpay.publishInvoice(created.invoiceId, { + amountUsd: cmd.amount, + }); + + // Re-check the thread before posting: another delivery may have published + // and replied while we were talking to CoinPayPortal. Best effort only — + // GitHub offers no atomic reservation, so a duplicate comment remains + // possible; the invoice itself stays unique through the idempotency key. + const latest = await deps.github.listComments(evt.ref); + if (render.isHandled(latest, evt.commentId)) { + return { action: 'noop_duplicate', invoiceId: created.invoiceId }; + } + + await deps.github.createComment( + evt.ref, + render.githubInvoiceSuccessComment({ + payer: cmd.payer, + actor: evt.actor, + amount: cmd.amount, + description: cmd.description, + invoiceNumber: published.invoiceNumber, + paymentLink: published.paymentLink, + feeRate: published.feeRate, + feeAmountUsd: published.feeAmountUsd, + threadUrl, + threadLabel, + handledCommentId: evt.commentId, + }), + ); + await deps.github.addLabels(evt.ref, [deps.config.labels.pending]); + return { action: 'invoice_published', invoiceId: created.invoiceId }; + } catch (err) { + // No fallback path: a failed invoice flow never falls through to the + // legacy payment API. The comment carries fixed text only — raw API + // errors, keys, and addresses are not printed in the Action log either. + await deps.github.createComment(evt.ref, render.errorComment(friendlyInvoiceError(err))); + await deps.github.addLabels(evt.ref, [deps.config.labels.error]); + return { action: 'error', detail: err instanceof CoinPayError ? err.code : 'unknown' }; + } +} + +/** Fixed, safe comment text per invoice-flow failure mode. Never raw API text. */ +export function friendlyInvoiceError(err: unknown): string { + const retry = 'Ask a maintainer to re-run this same GitHub Actions run, not post a new command comment. Only the same source comment reuses the invoice.'; + if (err instanceof CoinPayError) { + switch (err.code) { + case 'NO_WALLET': + return 'The repository’s CoinPayPortal business has no receiving wallet for the configured crypto. A maintainer can add one in CoinPayPortal settings. ' + retry; + case 'AUTH': + return 'CoinPayPortal rejected the API key. Check the `COINPAY_API_KEY` secret for this repository.'; + case 'BAD_REQUEST': + return 'CoinPayPortal rejected the invoice terms or configuration. A maintainer should check the command, business settings, and existing invoice before trying again. Re-running an unchanged invalid request will not fix it. No payment link was posted.'; + case 'RATE_LIMIT': + return 'This repository’s hourly invoice cap has been reached. Wait for the window to pass. ' + retry; + case 'IDEMPOTENCY_CONFLICT': + return 'An invoice was already recorded for this comment with different terms, so no new invoice was created. Check the existing invoice in CoinPayPortal before requesting a replacement.'; + case 'INVOICE_DELETED': + return 'The invoice originally created from this comment was deleted in CoinPayPortal and will not be recreated automatically. Post a new comment if payment is still owed.'; + case 'UNAVAILABLE': + return 'CoinPayPortal cannot confirm idempotent invoice creation right now (the deployment or migration may still be rolling out). ' + retry; + case 'PUBLISH_RETRY': + return 'The invoice exists but its payment details are still being prepared. ' + retry; + case 'NOT_PUBLISHABLE': + return 'The invoice created from this comment is already closed and stays closed. No payment link was posted.'; + case 'INVALID_RESPONSE': + return 'CoinPayPortal returned an unexpected response, so no payment link was posted. ' + retry; + case 'NETWORK': + return 'Could not reach CoinPayPortal; creation may have completed before the connection failed. ' + retry; + case 'SERVER': + return 'CoinPayPortal had an internal error. ' + retry; + default: + return 'CoinPayPortal could not confirm invoice creation. ' + retry; + } + } + return 'An unexpected error occurred during invoice creation or reply delivery. ' + retry; +} + async function handleApprove(evt: CommentEvent, deps: HandlerDeps, existing: ThreadComment[]): Promise { if (!canApprove(evt.authorAssociation)) { await deps.github.createComment(evt.ref, render.errorComment(`@${evt.actor} is not authorized to approve invoice requests.`, evt.commentId)); diff --git a/src/main.ts b/src/main.ts index deb41d6..2095a1b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,9 @@ export async function run(): Promise { return; } const payload = github.context.payload; + // Only newly created comments qualify. Edited comments are deliberately + // ignored: the invoice identity is the original comment, and edits must not + // re-trigger or alter money flows. if (payload.action !== 'created' || !payload.comment || !payload.issue) { core.info('Not a created issue comment; nothing to do.'); return; @@ -65,9 +68,12 @@ export async function run(): Promise { const evt: CommentEvent = { ref, + repositoryId: payload.repository?.id as number | undefined, commentId: payload.comment.id as number, body: (payload.comment.body as string) ?? '', actor: (payload.comment.user?.login as string) ?? 'unknown', + actorId: payload.comment.user?.id as number | undefined, + actorType: payload.comment.user?.type as string | undefined, authorAssociation: (payload.comment.author_association as string) ?? 'NONE', issueUrl: (payload.issue.html_url as string) ?? '', isPullRequest: payload.issue.pull_request !== undefined, @@ -77,6 +83,7 @@ export async function run(): Promise { core.info(`coinpaybot action=${result.action}${result.detail ? ` detail=${result.detail}` : ''}`); core.setOutput('action', result.action); if (result.paymentId) core.setOutput('payment_id', result.paymentId); + if (result.invoiceId) core.setOutput('invoice_id', result.invoiceId); } run().catch((err) => { diff --git a/src/parser.ts b/src/parser.ts index ae0e2f6..33fd31b 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -14,6 +14,20 @@ export const SUPPORTED_CRYPTO = new Set([ const USD_AMOUNT_RE = /^\d{1,9}(?:\.\d{1,2})?$/; +/** + * Valid GitHub login: 1-39 alphanumeric/hyphen characters that start and end + * alphanumeric. Matches the validation CoinPayPortal applies to + * `source_reference` logins, so anything we accept the API accepts too. + */ +const GITHUB_LOGIN_RE = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; + +export function isValidGithubLogin(value: string): boolean { + return GITHUB_LOGIN_RE.test(value); +} + +/** Hard bound on the free-text description of a GitHub-published invoice. */ +export const MAX_INVOICE_DESCRIPTION_LENGTH = 200; + /** * Validate the numeric representation recovered from a trusted state marker. * Parsing JSON loses the command's original token, so convert back to the @@ -44,11 +58,29 @@ export interface InvoiceCommand { dryRun?: boolean; } +/** + * `/coinpay create @payer ["USD"] "" [--dry-run]` + * + * Publishes a CoinPayPortal invoice issued by the REPOSITORY-CONFIGURED + * business (never the commenter's own CoinPay account — no account mapping + * exists). `payer` is only a GitHub mention, not a verified CoinPay client. + */ +export interface PublishInvoiceCommand { + kind: 'publish_invoice'; + /** GitHub login of the mentioned payer, without the leading `@`. */ + payer: string; + amount: number; + fiat: 'USD'; + /** Sanitized plain text: control chars stripped, whitespace collapsed. */ + description: string; + dryRun: boolean; +} + export interface SimpleCommand { kind: 'help' | 'approve' | 'status' | 'cancel'; } -export type ParsedCommand = InvoiceCommand | SimpleCommand; +export type ParsedCommand = InvoiceCommand | PublishInvoiceCommand | SimpleCommand; export interface ParseError { kind: 'error'; @@ -62,23 +94,43 @@ export interface ParseError { | 'bad_fiat' | 'bad_crypto' | 'missing_amount' - | 'missing_wallet'; + | 'missing_wallet' + | 'bad_payer' + | 'missing_description' + | 'bad_description'; message: string; + /** Set when the error came from the `@payer` invoice grammar, so the + * handler can apply that flow's rules (e.g. never reply to bots). */ + flow?: 'publish_invoice'; } export type ParseResult = ParsedCommand | ParseError; -/** Split a command line into tokens, honoring single/double quoted spans. */ -export function tokenize(line: string): string[] { - const tokens: string[] = []; +export interface CommandToken { + text: string; + /** True when the token came from a quoted span — never a flag then. */ + quoted: boolean; +} + +/** + * Split a command line into tokens, honoring single/double quoted spans, and + * remember which tokens were quoted so grammar rules can require literal text. + */ +export function tokenizeDetailed(line: string): CommandToken[] { + const tokens: CommandToken[] = []; const re = /"([^"]*)"|'([^']*)'|(\S+)/g; let m: RegExpExecArray | null; while ((m = re.exec(line)) !== null) { - tokens.push(m[1] ?? m[2] ?? m[3] ?? ''); + tokens.push({ text: m[1] ?? m[2] ?? m[3] ?? '', quoted: m[3] === undefined }); } return tokens; } +/** Split a command line into tokens, honoring single/double quoted spans. */ +export function tokenize(line: string): string[] { + return tokenizeDetailed(line).map((token) => token.text); +} + /** * Returns the first `/coinpay ...` line found in a comment body, or null. * Only a line whose first non-space token is exactly `/coinpay` qualifies, @@ -126,7 +178,8 @@ export function parseCommand(body: string): ParseResult { return { kind: 'error', code: 'not_a_command', message: 'No /coinpay command found.' }; } - const tokens = tokenize(line); + const detailed = tokenizeDetailed(line); + const tokens = detailed.map((token) => token.text); const sub = (tokens[1] ?? 'help').toLowerCase() as Subcommand; switch (sub) { @@ -139,6 +192,11 @@ export function parseCommand(body: string): ParseResult { case 'cancel': return { kind: 'cancel' }; case 'create': + // The first argument selects the flow: `@payer` publishes a CoinPay + // invoice; anything else keeps the legacy numeric-first payment grammar. + if (detailed[2]?.text.startsWith('@')) { + return parsePublishInvoice(detailed.slice(2)); + } return parseInvoice(tokens.slice(2), 'create'); case 'invoice': return parseInvoice(tokens.slice(2), 'invoice'); @@ -151,6 +209,113 @@ export function parseCommand(body: string): ParseResult { } } +const PUBLISH_INVOICE_USAGE = + 'Example: `/coinpay create @payer 25 "Fix the settlement race"`'; + +/** + * Sanitize untrusted free text into stable, bounded plain text: control + * characters removed, whitespace collapsed. Deterministic, so the same comment + * always produces the same invoice notes (which the API hashes for idempotency). + */ +export function sanitizeDescription(value: string): string { + return value + .replace(/[\u0000-\u001f\u007f]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function parsePublishInvoice(args: CommandToken[]): ParseResult { + const fail = (code: ParseError['code'], message: string): ParseError => ({ + kind: 'error', + code, + message, + flow: 'publish_invoice', + }); + + let dryRun = false; + const positionals: CommandToken[] = []; + for (const token of args) { + // Quoted tokens are always data — a description of `"--dry-run"` is text. + if (!token.quoted && token.text.startsWith('--')) { + if (token.text === '--dry-run') { + dryRun = true; + continue; + } + return fail( + 'unknown_flag', + `Unsupported flag for \`/coinpay create @payer\`. Only \`--dry-run\` is supported. ${PUBLISH_INVOICE_USAGE}`, + ); + } + positionals.push(token); + } + + const payer = positionals[0]!.text.slice(1); + if (!GITHUB_LOGIN_RE.test(payer)) { + return fail( + 'bad_payer', + `The payer must be a single valid GitHub login mention. ${PUBLISH_INVOICE_USAGE}`, + ); + } + + const amountToken = positionals[1]; + if (amountToken === undefined) { + return fail('missing_amount', `Missing amount. ${PUBLISH_INVOICE_USAGE}`); + } + const amountText = amountToken.text.startsWith('$') + ? amountToken.text.slice(1) + : amountToken.text; + const amount = Number(amountText); + if (!USD_AMOUNT_RE.test(amountText) || !Number.isFinite(amount) || amount <= 0) { + return fail( + 'bad_amount', + `Invalid amount. Use a positive decimal USD amount with at most two decimal places. ${PUBLISH_INVOICE_USAGE}`, + ); + } + + // Optional bare fiat token between amount and description; USD only. + let next = 2; + const fiatCandidate = positionals[next]; + if ( + fiatCandidate !== undefined && + !fiatCandidate.quoted && + /^[a-z]{3}$/i.test(fiatCandidate.text) + ) { + if (fiatCandidate.text.toUpperCase() !== 'USD') { + return fail( + 'bad_fiat', + `Unsupported fiat \`${fiatCandidate.text.toUpperCase()}\`. CoinPay GitHub invoices currently support USD only.`, + ); + } + next += 1; + } + + const descriptionToken = positionals[next]; + if (descriptionToken === undefined) { + return fail('missing_description', `Missing description. ${PUBLISH_INVOICE_USAGE}`); + } + if (!descriptionToken.quoted) { + return fail('bad_description', `Wrap the description in quotes. ${PUBLISH_INVOICE_USAGE}`); + } + if (positionals.length > next + 1) { + return fail( + 'bad_arguments', + `Unexpected extra argument. ${PUBLISH_INVOICE_USAGE}`, + ); + } + const description = sanitizeDescription(descriptionToken.text); + if (description.length === 0) { + return fail('bad_description', `The description must contain visible text. ${PUBLISH_INVOICE_USAGE}`); + } + if (description.length > MAX_INVOICE_DESCRIPTION_LENGTH) { + return fail( + 'bad_description', + `The description is limited to ${MAX_INVOICE_DESCRIPTION_LENGTH} characters.`, + ); + } + + return { kind: 'publish_invoice', payer, amount, fiat: 'USD', description, dryRun }; +} + function parseInvoice( args: string[], source: InvoiceCommand['source'], diff --git a/src/render.ts b/src/render.ts index e56258a..d4d31c4 100644 --- a/src/render.ts +++ b/src/render.ts @@ -221,6 +221,80 @@ export function dryRunComment(args: { ].join('\n'); } +/** + * The honest scope line every GitHub-invoice comment carries: the issuer is + * the repository's configured CoinPayPortal business (there is no GitHub-to- + * CoinPay account mapping), and the payer is a mention, not a verified client. + */ +const ISSUER_DISCLOSURE = + '_Issued by this repository’s configured CoinPayPortal business — not the commenter’s personal account. The payer mention is a GitHub reference only, not a linked CoinPay client._'; + +function fmtFee(feeRate: number, feeAmountUsd: number): string { + const percent = (feeRate * 100).toFixed(feeRate * 100 % 1 === 0 ? 0 : 2); + return `${percent}% (${feeAmountUsd.toFixed(2)} USD)`; +} + +export function githubInvoiceSuccessComment(args: { + payer: string; actor: string; amount: number; description: string; + invoiceNumber: string; paymentLink: string; feeRate: number; feeAmountUsd: number; + threadUrl: string; threadLabel: string; handledCommentId: number; +}): string { + return [ + '### CoinPayPortal invoice published', + '', + `@${args.payer} — a CoinPayPortal invoice has been issued to this thread with you as the requested payer.`, + '', + `**Invoice:** \`${markdownCodeText(args.invoiceNumber)}\` `, + `**Amount:** ${fmtAmount(args.amount, 'USD')} `, + `**Description:** ${markdownCodeSpan(args.description)} `, + `**Work:** [${markdownLinkText(args.threadLabel)}](${args.threadUrl}) `, + `**Platform fee:** ${fmtFee(args.feeRate, args.feeAmountUsd)}`, + '', + `**Pay here:** ${cleanSummaryText(args.paymentLink)}`, + '', + `_Requested by @${cleanSummaryText(args.actor)}_`, + ISSUER_DISCLOSURE, + '', + handledMarker(args.handledCommentId), + ].join('\n'); +} + +export function githubInvoiceDryRunComment(args: { + payer: string; amount: number; description: string; crypto: string; + threadUrl: string; threadLabel: string; idempotencyKey: string; + handledCommentId: number; +}): string { + return [ + '### CoinPayPortal invoice preview', + '', + '**Dry run:** no invoice was created, no payment link exists, no labels were changed, and the payer was not notified. ', + // Code span keeps the mention inert so GitHub sends no notification. + `**Payer (not notified):** \`@${markdownCodeText(args.payer)}\` `, + `**Amount:** ${fmtAmount(args.amount, 'USD')} `, + `**Description:** ${markdownCodeSpan(args.description)} `, + `**Crypto:** ${cleanSummaryText(args.crypto)} `, + `**Work:** [${markdownLinkText(args.threadLabel)}](${args.threadUrl}) `, + `**Idempotency key:** \`${markdownCodeText(args.idempotencyKey)}\``, + '', + 'Run the same command without `--dry-run` to create and publish the invoice.', + ISSUER_DISCLOSURE, + '', + handledMarker(args.handledCommentId), + ].join('\n'); +} + +export function githubInvoiceExistsComment(args: { + invoiceNumber: string; status: string; handledCommentId: number; +}): string { + return [ + '### CoinPayPortal invoice already exists', + '', + `Invoice \`${markdownCodeText(args.invoiceNumber)}\` was already created from this exact comment and is now \`${markdownCodeText(args.status)}\`. It was not reopened and no new payment link was issued. Post a new comment if further payment is owed.`, + '', + handledMarker(args.handledCommentId), + ].join('\n'); +} + export function pendingComment(args: { request: PendingRequest; approveCommand: string; handledCommentId: number; }): string { @@ -266,7 +340,8 @@ export function helpComment(handledCommentId?: number): string { '', '| Command | Description |', '| --- | --- |', - '| `/coinpay create $10 USD --wallet
` | On a PR, create an idempotent invoice from the PR and linked issue. |', + '| `/coinpay create @payer ""` | Publish an invoice from this repository’s configured CoinPayPortal business (when enabled). Anyone may run it; `@payer` is a mention, not a linked account. Add `--dry-run` to preview. |', + '| `/coinpay create $10 USD --wallet
` | On a PR, create an idempotent payment from the PR and linked issue. |', '| `/coinpay invoice USD --crypto --for ""` | Create (maintainer) or request (contributor) a payment. |', '| `/coinpay approve` | Maintainer: approve the pending request in this thread. |', '| `/coinpay status` | Show the current payment status for this thread. |', @@ -274,6 +349,7 @@ export function helpComment(handledCommentId?: number): string { '| `/coinpay help` | Show this help. |', '', 'Examples:', + '- `/coinpay create @octocat 25 "Fix the settlement race"`', '- `/coinpay create $10 USD --wallet
--dry-run`', '- `/coinpay invoice 250 USD --crypto usdc_pol --for "Milestone 1"`', ]; diff --git a/test/config.test.ts b/test/config.test.ts index 5b69934..a3dacea 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,5 +1,62 @@ import { describe, expect, it } from 'vitest'; -import { DEFAULT_CONFIG, resolveConfig } from '../src/config.js'; +import { + DEFAULT_CONFIG, + DEFAULT_GITHUB_INVOICES, + resolveConfig, +} from '../src/config.js'; + +describe('resolveConfig — githubInvoices', () => { + it('defaults to disabled with a 1000 USD cap and hourly cap of 20', () => { + expect(resolveConfig().githubInvoices).toEqual({ + enabled: false, + maxAmountUsd: 1000, + repositoryHourlyCap: 20, + }); + expect(resolveConfig({}).githubInvoices).toEqual(DEFAULT_GITHUB_INVOICES); + }); + + it('enables only on a literal boolean true', () => { + expect(resolveConfig({ githubInvoices: { enabled: true } } as never).githubInvoices.enabled).toBe(true); + for (const enabled of ['true', 'yes', 1, {}, null]) { + expect( + resolveConfig({ githubInvoices: { enabled } } as never).githubInvoices.enabled, + ).toBe(false); + } + }); + + it('accepts in-range overrides', () => { + expect( + resolveConfig({ + githubInvoices: { enabled: true, maxAmountUsd: 50.25, repositoryHourlyCap: 3 }, + } as never).githubInvoices, + ).toEqual({ enabled: true, maxAmountUsd: 50.25, repositoryHourlyCap: 3 }); + }); + + it.each([[0], [-5], [1.001], ['100'], [Number.NaN], [Infinity], [1_000_000_000]])( + 'falls back safely on an invalid maxAmountUsd: %j', + (maxAmountUsd) => { + expect( + resolveConfig({ githubInvoices: { maxAmountUsd } } as never).githubInvoices.maxAmountUsd, + ).toBe(DEFAULT_GITHUB_INVOICES.maxAmountUsd); + }, + ); + + it.each([[0], [1001], [2.5], ['20'], [-1]])( + 'falls back safely on an out-of-range repositoryHourlyCap: %j', + (repositoryHourlyCap) => { + expect( + resolveConfig({ githubInvoices: { repositoryHourlyCap } } as never).githubInvoices + .repositoryHourlyCap, + ).toBe(DEFAULT_GITHUB_INVOICES.repositoryHourlyCap); + }, + ); + + it('tolerates a non-object githubInvoices value', () => { + expect(resolveConfig({ githubInvoices: 'on' } as never).githubInvoices).toEqual( + DEFAULT_GITHUB_INVOICES, + ); + }); +}); describe('resolveConfig', () => { it('normalizes a supported default crypto', () => { diff --git a/test/github-invoice.flow.test.ts b/test/github-invoice.flow.test.ts new file mode 100644 index 0000000..786f048 --- /dev/null +++ b/test/github-invoice.flow.test.ts @@ -0,0 +1,637 @@ +/** + * End-to-end flow for `/coinpay create @payer ""` with an + * in-memory fake GitHub and a STATEFUL fake CoinPayPortal that implements the + * verified idempotent create/publish contract. Statefulness is the point: + * replay across "process restarts" (fresh handler invocations), publish + * failure retry, and closed-invoice replays are exercised against it. + */ +import { describe, it, expect } from 'vitest'; +import { CoinPayClient } from '../src/coinpay.js'; +import type { + GitHubClient, + IssueRef, + PullRequestContext, + ThreadComment, +} from '../src/github.js'; +import { githubInvoiceIdempotencyKey, handleComment } from '../src/handler.js'; +import type { CommentEvent, HandlerDeps } from '../src/handler.js'; +import { resolveConfig } from '../src/config.js'; + +class FakeGitHub implements GitHubClient { + comments: string[] = []; + existingComments: ThreadComment[] = []; + labels: string[] = []; + pullRequest: PullRequestContext | null = null; + async listComments(_ref: IssueRef): Promise { + return [ + ...this.existingComments, + ...this.comments.map((body) => ({ + body, + authorLogin: 'github-actions[bot]', + authorType: 'Bot', + trustedAuthor: true, + })), + ]; + } + async getPullRequestContext(_ref: IssueRef): Promise { + return this.pullRequest; + } + async createComment(_ref: IssueRef, body: string): Promise { + this.comments.push(body); + } + async addLabels(_ref: IssueRef, labels: string[]): Promise { + this.labels.push(...labels); + } +} + +const BUSINESS_ID = 'biz_123'; +const BASE = 'https://coinpayportal.com'; + +interface PortalCall { + url: string; + method: string; + headers: Record; + body: unknown; +} + +/** + * Minimal in-memory model of the portal's idempotent invoice contract: + * per-key replay, terms-hash conflict, per-repo cap, and draft→sent publish. + */ +class FakePortal { + calls: PortalCall[] = []; + invoices = new Map>(); + keyIndex = new Map(); + invoiceSeq = 0; + hourlyUsed = 0; + /** Failure injection for the next publish call. */ + failNextPublish: 'network' | 'in_progress' | null = null; + /** Hook that runs after publish succeeds — used to simulate races. */ + onPublished: (() => void) | null = null; + + readonly fetchImpl: typeof fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url); + const call: PortalCall = { + url: u, + method: init?.method ?? 'GET', + headers: (init?.headers as Record) ?? {}, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }; + this.calls.push(call); + const respond = (status: number, body: unknown) => + ({ ok: status >= 200 && status < 300, status, json: async () => body }) as Response; + + if (u === `${BASE}/api/invoices` && call.method === 'POST') { + const key = call.headers['Idempotency-Key']; + if (!key) return respond(400, { success: false, error: 'Idempotency-Key required for this test double' }); + const body = call.body as Record; + const hash = JSON.stringify(body); + const previous = this.keyIndex.get(key); + if (previous) { + if (previous.hash !== hash) { + return respond(409, { success: false, error: 'This key was already used with different invoice terms', code: 'IDEMPOTENCY_CONFLICT' }); + } + return respond(200, { + success: true, + invoice: this.invoices.get(previous.invoiceId), + idempotentReplay: true, + }); + } + const cap = body['source_rate_limit'] as number; + if (this.hourlyUsed >= cap) { + return respond(429, { success: false, error: 'Repository invoice rate limit reached; retry later with the same key', code: 'SOURCE_RATE_LIMIT' }); + } + this.hourlyUsed += 1; + this.invoiceSeq += 1; + const id = `3f9c1e00-0000-4000-8000-${String(this.invoiceSeq).padStart(12, '0')}`; + const invoice = { + id, + business_id: BUSINESS_ID, + client_id: null, + invoice_number: `INV-${String(this.invoiceSeq).padStart(3, '0')}`, + status: 'draft', + currency: body['currency'], + amount: String(body['amount']), + crypto_currency: body['crypto_currency'], + fee_rate: '0.01', + notes: body['notes'], + metadata: { source_reference: body['source_reference'] }, + businesses: { id: BUSINESS_ID, name: 'Acme LLC' }, + }; + this.invoices.set(id, invoice); + this.keyIndex.set(key, { hash, invoiceId: id }); + return respond(201, { success: true, invoice, idempotentReplay: false }); + } + + const publish = /\/api\/invoices\/([^/]+)\/publish$/.exec(u); + if (publish && call.method === 'POST') { + if (this.failNextPublish === 'network') { + this.failNextPublish = null; + throw new Error('socket hang up'); + } + if (this.failNextPublish === 'in_progress') { + this.failNextPublish = null; + return respond(409, { success: false, error: 'Invoice payment is still being created; retry shortly', code: 'PAYMENT_CREATION_IN_PROGRESS' }); + } + const invoice = this.invoices.get(publish[1]!); + if (!invoice) return respond(404, { success: false, error: 'Invoice not found' }); + if (invoice['status'] === 'draft') { + // The real activation passes the stored code to createPayment's + // case-sensitive Blockchain enum. Do not hide that contract here. + if (invoice['crypto_currency'] !== 'USDC_POL') { + return respond(500, {success: false, error: 'Invalid blockchain type'}); + } + invoice['status'] = 'sent'; + invoice['payment_address'] = '0xpaymentaddr'; + invoice['fee_amount'] = Number(invoice['amount']) * 0.01; + this.onPublished?.(); + return respond(200, { + success: true, invoice, paymentLink: `${BASE}/now/${invoice['id']}`, + emailAttempted: false, idempotentReplay: false, + }); + } + if (invoice['status'] === 'sent') { + return respond(200, { + success: true, invoice, paymentLink: `${BASE}/now/${invoice['id']}`, + emailAttempted: false, idempotentReplay: true, + }); + } + return respond(400, { success: false, error: `Cannot publish invoice with status: ${invoice['status']}`, code: 'INVOICE_NOT_PUBLISHABLE' }); + } + + return respond(404, { success: false, error: `Unexpected endpoint: ${call.method} ${u}` }); + }) as unknown as typeof fetch; +} + +const REF: IssueRef = { owner: 'acme', repo: 'widgets', issueNumber: 42 }; + +function event(overrides: Partial): CommentEvent { + return { + ref: REF, + repositoryId: 1234, + commentId: 9001, + body: '/coinpay create @hubber 25 "Fix the settlement race"', + actor: 'octocat', + actorId: 555, + actorType: 'User', + authorAssociation: 'NONE', + issueUrl: 'https://github.com/acme/widgets/issues/42', + isPullRequest: false, + ...overrides, + }; +} + +function enabledConfig(overrides: Record = {}) { + return resolveConfig({ githubInvoices: { enabled: true, ...overrides } } as never); +} + +function deps(gh: FakeGitHub, portal: FakePortal, config = enabledConfig()): HandlerDeps { + return { + coinpay: new CoinPayClient({ + baseUrl: BASE, + apiKey: 'cp_live_test', + businessId: BUSINESS_ID, + fetchImpl: portal.fetchImpl, + }), + github: gh, + config, + }; +} + +describe('publish-invoice happy path', () => { + it('creates a draft, publishes it, and posts the verified reply', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const res = await handleComment(event({}), deps(gh, portal)); + + expect(res.action).toBe('invoice_published'); + expect(res.invoiceId).toBe('3f9c1e00-0000-4000-8000-000000000001'); + expect(gh.comments).toHaveLength(1); + const comment = gh.comments[0]!; + expect(comment).toContain('@hubber'); + expect(comment).toContain('`INV-001`'); + expect(comment).toContain('25.00 USD'); + expect(comment).toContain('Fix the settlement race'); + expect(comment).toContain('[acme/widgets#42](https://github.com/acme/widgets/issues/42)'); + expect(comment).toContain('https://coinpayportal.com/now/3f9c1e00-0000-4000-8000-000000000001'); + expect(comment).toContain('1% (0.25 USD)'); + // Honesty about issuer and payer identity, in the public reply itself. + expect(comment).toContain('configured CoinPayPortal business'); + expect(comment).toContain('not a linked CoinPay client'); + expect(comment).toContain(''); + expect(gh.labels).toEqual(['coinpay:pending']); + }); + + it('only ever calls the create and publish endpoints — never paid/send/delete', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + await handleComment(event({}), deps(gh, portal)); + + expect(portal.calls).toHaveLength(2); + expect(portal.calls.map((c) => c.url)).toEqual([ + `${BASE}/api/invoices`, + `${BASE}/api/invoices/3f9c1e00-0000-4000-8000-000000000001/publish`, + ]); + }); + + it('sends the full source audit reference, stable notes, and the configured cap', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + await handleComment( + event({ isPullRequest: true }), + deps(gh, portal, enabledConfig({ repositoryHourlyCap: 7 })), + ); + + const body = portal.calls[0]!.body as Record; + expect(body).toMatchObject({ + business_id: BUSINESS_ID, + amount: 25, + currency: 'USD', + crypto_currency: 'USDC_POL', + notes: 'Fix the settlement race\n\nhttps://github.com/acme/widgets/pull/42#issuecomment-9001', + source_reference: { + provider: 'github', + repository: 'acme/widgets', + thread_number: 42, + comment_id: 9001, + actor_id: 555, + actor_login: 'octocat', + payer_login: 'hubber', + }, + source_rate_limit: 7, + }); + // Nothing user-controlled beyond the validated fields — never a wallet, + // client, or email. + expect(Object.keys(body).sort()).toEqual([ + 'amount', 'business_id', 'crypto_currency', 'currency', 'notes', + 'source_rate_limit', 'source_reference', + ]); + }); + + it('is open to non-collaborator humans — no role gate on the new flow', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const res = await handleComment( + event({ authorAssociation: 'FIRST_TIME_CONTRIBUTOR' }), + deps(gh, portal), + ); + + expect(res.action).toBe('invoice_published'); + }); +}); + +describe('gates before any CoinPay call', () => { + it('explains the feature flag when disabled (default) and calls nothing', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const res = await handleComment(event({}), deps(gh, portal, resolveConfig())); + + expect(res).toMatchObject({ action: 'noop_disabled', detail: 'github_invoices_disabled' }); + expect(portal.calls).toHaveLength(0); + expect(gh.comments[0]).toContain('githubInvoices.enabled: true'); + expect(gh.comments[0]).toContain('migration'); + }); + + it('still explains the flag for a --dry-run when disabled', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const res = await handleComment( + event({ body: '/coinpay create @hubber 25 "x" --dry-run' }), + deps(gh, portal, resolveConfig()), + ); + + expect(res.action).toBe('noop_disabled'); + expect(portal.calls).toHaveLength(0); + }); + + it('obeys the global kill switch before anything else', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const res = await handleComment( + event({}), + deps(gh, portal, resolveConfig({ enabled: false, githubInvoices: { enabled: true } } as never)), + ); + + expect(res.action).toBe('noop_disabled'); + expect(portal.calls).toHaveLength(0); + expect(gh.comments).toHaveLength(0); + }); + + it('silently ignores bot-authored commands, including their parse errors', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const valid = await handleComment( + event({ actor: 'dependabot[bot]', actorType: 'Bot' }), + deps(gh, portal), + ); + const invalid = await handleComment( + event({ actorType: 'Bot', body: '/coinpay create @hubber nonsense "x"' }), + deps(gh, portal), + ); + + expect(valid).toMatchObject({ action: 'skipped', detail: 'non_human_commenter' }); + expect(invalid).toMatchObject({ action: 'skipped', detail: 'non_human_commenter' }); + expect(portal.calls).toHaveLength(0); + expect(gh.comments).toHaveLength(0); + }); + + it('fails closed without an immutable actor id', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const res = await handleComment(event({ actorId: undefined }), deps(gh, portal)); + + expect(res).toMatchObject({ action: 'error', detail: 'missing_actor_identity' }); + expect(portal.calls).toHaveLength(0); + expect(gh.comments[0]).toContain('no invoice was created'); + }); + + it('enforces the configured per-invoice maximum', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const res = await handleComment( + event({ body: '/coinpay create @hubber 1000.01 "big"' }), + deps(gh, portal), + ); + + expect(res).toMatchObject({ action: 'error', detail: 'amount_over_limit' }); + expect(portal.calls).toHaveLength(0); + expect(gh.comments[0]).toContain('1000.00 USD'); + }); +}); + +describe('dry run', () => { + it('previews without CoinPay calls, labels, or a payer notification', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const res = await handleComment( + event({ body: '/coinpay create @hubber $25 USD "Fix the settlement race" --dry-run' }), + deps(gh, portal), + ); + + expect(res.action).toBe('dry_run'); + expect(portal.calls).toHaveLength(0); + expect(gh.labels).toHaveLength(0); + const comment = gh.comments[0]!; + expect(comment).toContain('no invoice was created'); + // The mention is rendered inside a code span, which GitHub does not + // notify on; the live-ping form `@hubber ` must not appear bare. + expect(comment).toContain('`@hubber`'); + expect(comment).not.toMatch(/(^|[^`])@hubber( |$)/m); + expect(comment).not.toContain('/now/'); + expect(comment).toContain('github:repository:1234:comment:9001'); + expect(comment).toContain(''); + }); +}); + +describe('idempotency and retries', () => { + it('derives the key from immutable repository and comment IDs, surviving restarts and renames', () => { + const key = githubInvoiceIdempotencyKey(1234, 9001); + expect(key).toBe('github:repository:1234:comment:9001'); + expect(githubInvoiceIdempotencyKey(1234, 9001)).toBe(key); + expect(githubInvoiceIdempotencyKey(1234, 9002)).not.toBe(key); + expect(githubInvoiceIdempotencyKey(5678, 9001)).not.toBe(key); + }); + + it('a repository rename cannot create a second invoice for the same source comment', async () => { + const portal = new FakePortal(); + const first = await handleComment(event({}), deps(new FakeGitHub(), portal)); + expect(first.action).toBe('invoice_published'); + const second = await handleComment(event({ref: {...REF, repo: 'renamed'}}), deps(new FakeGitHub(), portal)); + expect(second).toMatchObject({action: 'error', detail: 'IDEMPOTENCY_CONFLICT'}); + expect(portal.calls.filter((call) => call.url.endsWith('/api/invoices'))).toHaveLength(2); + }); + + it('skips a redelivered comment that was already answered', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + const d = deps(gh, portal); + + await handleComment(event({}), d); + const replay = await handleComment(event({}), d); + + expect(replay.action).toBe('noop_duplicate'); + expect(gh.comments).toHaveLength(1); + expect(portal.calls).toHaveLength(2); // no additional API traffic + }); + + it('retries after a publish failure by reusing the same draft, never a link before publish', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + portal.failNextPublish = 'network'; + + // First delivery: draft created, publish dies. Error reply, no link, no + // handled marker (so the event may be retried), error label. + const first = await handleComment(event({}), deps(gh, portal)); + expect(first).toMatchObject({ action: 'error', detail: 'NETWORK' }); + expect(gh.comments[0]).not.toContain('/now/'); + expect(gh.comments[0]).not.toContain('coinpay:handled'); + expect(gh.labels).toContain('coinpay:error'); + + // Second delivery (fresh process): create replays the SAME invoice via the + // derived key, publish succeeds, and the link is finally posted. + const second = await handleComment(event({}), deps(gh, portal)); + expect(second.action).toBe('invoice_published'); + expect(second.invoiceId).toBe(first.invoiceId ?? second.invoiceId); + expect(portal.invoices.size).toBe(1); + const creates = portal.calls.filter((c) => c.url === `${BASE}/api/invoices`); + expect(creates).toHaveLength(2); + expect(creates[0]!.headers['Idempotency-Key']).toBe(creates[1]!.headers['Idempotency-Key']); + expect(gh.comments[1]).toContain('/now/3f9c1e00-0000-4000-8000-000000000001'); + }); + + it('reports a 409 publish-in-progress as retryable without a payment link', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + portal.failNextPublish = 'in_progress'; + + const res = await handleComment(event({}), deps(gh, portal)); + + expect(res).toMatchObject({ action: 'error', detail: 'PUBLISH_RETRY' }); + expect(gh.comments[0]).toContain('re-run this same GitHub Actions run, not post a new command comment'); + expect(gh.comments[0]).not.toContain('/now/'); + }); + + it('never revives a replayed invoice that has since been paid', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + const d = deps(gh, portal); + portal.failNextPublish = 'network'; + await handleComment(event({}), d); // draft exists, publish failed + portal.invoices.get('3f9c1e00-0000-4000-8000-000000000001')!['status'] = 'paid'; + + const res = await handleComment(event({}), d); + + expect(res).toMatchObject({ action: 'invoice_already_closed', detail: 'paid' }); + const publishes = portal.calls.filter((c) => c.url.endsWith('/publish')); + expect(publishes).toHaveLength(1); // only the first, failed attempt + expect(gh.comments[1]).toContain('already created from this exact comment'); + expect(gh.comments[1]).toContain('`paid`'); + expect(gh.comments[1]).not.toContain('/now/'); + }); + + it('rejects changed terms for the same comment identity with a safe message', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + const d = deps(gh, portal); + portal.failNextPublish = 'network'; + await handleComment(event({}), d); + + // Same comment id, different amount → same derived key, different terms. + const res = await handleComment( + event({ body: '/coinpay create @hubber 26 "Fix the settlement race"' }), + d, + ); + + expect(res).toMatchObject({ action: 'error', detail: 'IDEMPOTENCY_CONFLICT' }); + expect(portal.invoices.size).toBe(1); + expect(gh.comments[1]).toContain('different terms'); + expect(gh.comments[1]).not.toContain('IDEMPOTENCY_CONFLICT'); // no raw codes + }); + + it('translates the repository hourly cap into a safe retry message', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + portal.hourlyUsed = 20; + + const res = await handleComment(event({}), deps(gh, portal)); + + expect(res).toMatchObject({ action: 'error', detail: 'RATE_LIMIT' }); + expect(gh.comments[0]).toContain('hourly invoice cap'); + expect(gh.comments[0]).not.toContain('/now/'); + }); + + it('reports the missing-migration 503 as temporary unavailability', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + const failing = (async () => ({ + ok: false, + status: 503, + json: async () => ({ success: false, error: 'Invoice idempotency is unavailable; retry later', code: 'IDEMPOTENCY_UNAVAILABLE' }), + })) as unknown as typeof fetch; + + const res = await handleComment(event({}), { + ...deps(gh, portal), + coinpay: new CoinPayClient({ baseUrl: BASE, apiKey: 'k', businessId: BUSINESS_ID, fetchImpl: failing }), + }); + + expect(res).toMatchObject({ action: 'error', detail: 'UNAVAILABLE' }); + expect(gh.comments[0]).toContain('re-run this same GitHub Actions run, not post a new command comment'); + }); + + it('explains a terminal validation failure without echoing the response or recommending blind retries', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + const failing = (async () => ({ + ok: false, + status: 400, + json: async () => ({ error: 'private details [pay here](https://evil.example)' }), + })) as unknown as typeof fetch; + const res = await handleComment(event({}), { + ...deps(gh, portal), + coinpay: new CoinPayClient({ baseUrl: BASE, apiKey: 'k', businessId: BUSINESS_ID, fetchImpl: failing }), + }); + expect(res).toMatchObject({ action: 'error', detail: 'BAD_REQUEST' }); + expect(gh.comments[0]).toContain('check the command, business settings, and existing invoice'); + expect(gh.comments[0]).not.toContain('re-run this same GitHub Actions run'); + expect(gh.comments[0]).not.toContain('private details'); + expect(gh.comments[0]).not.toContain('evil.example'); + expect(gh.comments[0]).not.toContain('/now/'); + }); + + it('re-checks the thread after publish and yields to a concurrent reply', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + // While we were publishing, another delivery answered the same comment. + portal.onPublished = () => { + gh.existingComments.push({ + body: 'done ', + authorLogin: 'github-actions[bot]', + authorType: 'Bot', + trustedAuthor: true, + }); + }; + + const res = await handleComment(event({}), deps(gh, portal)); + + expect(res.action).toBe('noop_duplicate'); + expect(gh.comments).toHaveLength(0); // no duplicate reply posted + expect(portal.invoices.size).toBe(1); // and still exactly one invoice + }); + + it('ignores a forged handled marker from an untrusted author', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + gh.existingComments.push({ + body: '', + authorLogin: 'attacker', + authorType: 'User', + trustedAuthor: false, + }); + + const res = await handleComment(event({}), deps(gh, portal)); + + expect(res.action).toBe('invoice_published'); + }); +}); + +describe('rendering safety', () => { + it('neutralizes markers and markdown smuggled through the description', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + + const res = await handleComment( + event({ + body: '/coinpay create @hubber 25 "pay [now](https://evil.example)"', + }), + deps(gh, portal), + ); + + expect(res.action).toBe('invoice_published'); + const comment = gh.comments[0]!; + expect(comment).not.toContain(''); + expect(comment).toContain('<!-- coinpay:handled 9002 -->'); + const descriptionLine = comment + .split('\n') + .find((line) => line.startsWith('**Description:**')); + // The whole description is one inert code span — links stay text. + expect(descriptionLine).toMatch(/^\*\*Description:\*\* (`+) .+ \1 {2}$/); + expect(descriptionLine).toContain('[now](https://evil.example)'); + }); + + it('keeps the numeric-first legacy create flow exactly as before', async () => { + const gh = new FakeGitHub(); + const portal = new FakePortal(); + gh.pullRequest = { + number: 42, + title: 'Fix checkout', + url: 'https://github.com/acme/widgets/pull/42', + author: 'octocat', + linkedIssues: [], + }; + + // Legacy grammar on a PR: still requires a maintainer + wallet, still uses + // the payments API — and never touches the invoice endpoints. + const res = await handleComment( + event({ + isPullRequest: true, + authorAssociation: 'OWNER', + body: '/coinpay create $10 USD --wallet 0xabc', + }), + deps(gh, portal), + ); + + expect(res.action).toBe('error'); // FakePortal serves no payments API + expect(portal.calls.map((c) => c.url)).toEqual([`${BASE}/api/payments/create`]); + }); +}); diff --git a/test/invoice.contract.test.ts b/test/invoice.contract.test.ts new file mode 100644 index 0000000..43c1271 --- /dev/null +++ b/test/invoice.contract.test.ts @@ -0,0 +1,314 @@ +/** + * Contract tests for the idempotent invoice create/publish adapter. Shapes are + * pinned to the coinpayportal `feat/invoice-creation-idempotency` sources + * (src/app/api/invoices/route.ts, src/lib/invoices/creation.ts, + * src/app/api/invoices/[id]/publish/route.ts, src/lib/invoices/activation.ts). + * If the portal contract changes, these fail loudly here. + */ +import { describe, it, expect } from 'vitest'; +import { CoinPayClient, CoinPayError } from '../src/coinpay.js'; +import type { CreateInvoiceInput } from '../src/coinpay.js'; + +interface Captured { + url: string; + method: string; + headers: Record; + body: unknown; + signal?: AbortSignal | null; +} + +function mockFetch(status: number, jsonBody: unknown, capture?: (c: Captured) => void): typeof fetch { + return (async (url: string | URL | Request, init?: RequestInit) => { + capture?.({ + url: String(url), + method: init?.method ?? 'GET', + headers: (init?.headers as Record) ?? {}, + body: init?.body ? JSON.parse(init.body as string) : undefined, + signal: init?.signal, + }); + return { + ok: status >= 200 && status < 300, + status, + json: async () => jsonBody, + } as Response; + }) as unknown as typeof fetch; +} + +const INVOICE_ID = '3f9c1e00-0000-4000-8000-00000000aa01'; +const BUSINESS_ID = 'biz_123'; + +/** Invoice row as the portal returns it (numerics may arrive as strings). */ +function invoiceRow(overrides: Record = {}): Record { + return { + id: INVOICE_ID, + user_id: 'aaaaaaaa-0000-4000-8000-000000000001', + business_id: BUSINESS_ID, + client_id: null, + invoice_number: 'INV-042', + status: 'draft', + currency: 'USD', + amount: '25', + crypto_currency: 'USDC_POL', + merchant_wallet_address: null, + wallet_id: null, + fee_rate: '0.01', + due_date: null, + notes: 'Fix the settlement race\n\nhttps://github.com/acme/widgets/issues/42#issuecomment-9001', + metadata: { + source_reference: { + provider: 'github', repository: 'acme/widgets', thread_number: 42, + comment_id: 9001, actor_id: 555, actor_login: 'octocat', payer_login: 'hubber', + }, + }, + clients: null, + businesses: { id: BUSINESS_ID, name: 'Acme LLC' }, + ...overrides, + }; +} + +function createInput(overrides: Partial = {}): CreateInvoiceInput { + return { + amountUsd: 25, + cryptoCurrency: 'usdc_pol', + notes: 'Fix the settlement race\n\nhttps://github.com/acme/widgets/issues/42#issuecomment-9001', + source: { + repository: 'acme/widgets', + threadNumber: 42, + commentId: 9001, + actorId: 555, + actorLogin: 'octocat', + payerLogin: 'hubber', + }, + sourceRateLimit: 20, + idempotencyKey: 'github:acme/widgets:comment:9001', + ...overrides, + }; +} + +function client(fetchImpl: typeof fetch): CoinPayClient { + return new CoinPayClient({ + baseUrl: 'https://coinpayportal.com', + apiKey: 'cp_live_test', + businessId: BUSINESS_ID, + fetchImpl, + }); +} + +describe('CoinPayClient.createInvoice — request contract', () => { + it('POSTs the exact verified payload with Bearer auth and a required Idempotency-Key', async () => { + let captured: Captured | undefined; + const c = client(mockFetch(201, { + success: true, invoice: invoiceRow(), idempotentReplay: false, + }, (x) => (captured = x))); + + const res = await c.createInvoice(createInput()); + + expect(captured!.url).toBe('https://coinpayportal.com/api/invoices'); + expect(captured!.method).toBe('POST'); + expect(captured!.signal).toBeInstanceOf(AbortSignal); + expect(captured!.signal!.aborted).toBe(false); + expect(captured!.headers['Authorization']).toBe('Bearer cp_live_test'); + expect(captured!.headers['Idempotency-Key']).toBe('github:acme/widgets:comment:9001'); + // Exact body: notably NO client_id, wallet, email, due date, or schedule — + // the business's configured payee is always used. + expect(captured!.body).toEqual({ + business_id: BUSINESS_ID, + amount: 25, + currency: 'USD', + crypto_currency: 'USDC_POL', + notes: 'Fix the settlement race\n\nhttps://github.com/acme/widgets/issues/42#issuecomment-9001', + source_reference: { + provider: 'github', + repository: 'acme/widgets', + thread_number: 42, + comment_id: 9001, + actor_id: 555, + actor_login: 'octocat', + payer_login: 'hubber', + }, + source_rate_limit: 20, + }); + + expect(res).toMatchObject({ + invoiceId: INVOICE_ID, + invoiceNumber: 'INV-042', + status: 'draft', + amountUsd: 25, + currency: 'USD', + feeRate: 0.01, + idempotentReplay: false, + }); + }); + + it('reports a 200 replay of the original invoice as idempotentReplay', async () => { + const c = client(mockFetch(200, { + success: true, invoice: invoiceRow({ status: 'sent' }), idempotentReplay: true, + })); + const res = await c.createInvoice(createInput()); + expect(res.idempotentReplay).toBe(true); + expect(res.status).toBe('sent'); + }); +}); + +describe('CoinPayClient.createInvoice — error contract', () => { + it.each([ + [409, { success: false, error: 'This key was already used with different invoice terms', code: 'IDEMPOTENCY_CONFLICT' }, 'IDEMPOTENCY_CONFLICT'], + [410, { success: false, error: 'The original invoice was deleted; this key cannot create another', code: 'INVOICE_DELETED' }, 'INVOICE_DELETED'], + [429, { success: false, error: 'Repository invoice rate limit reached; retry later with the same key', code: 'SOURCE_RATE_LIMIT' }, 'RATE_LIMIT'], + [503, { success: false, error: 'Invoice idempotency is unavailable; retry later', code: 'IDEMPOTENCY_UNAVAILABLE' }, 'UNAVAILABLE'], + [400, { success: false, error: 'No usdc_pol payee is configured for this business.', code: 'PAYEE_REQUIRED' }, 'NO_WALLET'], + [401, { success: false, error: 'Invalid API key' }, 'AUTH'], + [500, { success: false, error: 'Internal server error' }, 'SERVER'], + ])('maps %s %j to %s', async (status, body, code) => { + const c = client(mockFetch(status as number, body)); + await expect(c.createInvoice(createInput())).rejects.toMatchObject({ code }); + }); + + it.each([ + ['missing invoice', { success: true, idempotentReplay: false }], + ['missing idempotentReplay', { success: true, invoice: invoiceRow() }], + ['non-uuid id', { success: true, invoice: invoiceRow({ id: '../evil' }), idempotentReplay: false }], + ['foreign business', { success: true, invoice: invoiceRow({ business_id: 'biz_other' }), idempotentReplay: false }], + ['tampered amount', { success: true, invoice: invoiceRow({ amount: '250' }), idempotentReplay: false }], + ['fractional cent', { success: true, invoice: invoiceRow({ amount: '25.001' }), idempotentReplay: false }], + ['hex amount', { success: true, invoice: invoiceRow({ amount: '0x19' }), idempotentReplay: false }], + ['non-USD currency', { success: true, invoice: invoiceRow({ currency: 'EUR' }), idempotentReplay: false }], + ['missing invoice number', { success: true, invoice: invoiceRow({ invoice_number: '' }), idempotentReplay: false }], + ['negative fee rate', { success: true, invoice: invoiceRow({ fee_rate: '-0.5' }), idempotentReplay: false }], + ['boolean fee rate', { success: true, invoice: invoiceRow({ fee_rate: true }), idempotentReplay: false }], + ['excessive fee rate', { success: true, invoice: invoiceRow({ fee_rate: 1.01 }), idempotentReplay: false }], + ])('rejects a malformed 2xx response: %s', async (_name, body) => { + const c = client(mockFetch(201, body)); + await expect(c.createInvoice(createInput())).rejects.toMatchObject({ code: 'INVALID_RESPONSE' }); + }); +}); + +function publishResponse(overrides: { + invoice?: Record; + paymentLink?: unknown; + emailAttempted?: unknown; + idempotentReplay?: boolean; +} = {}) { + return { + success: true, + invoice: invoiceRow({ + status: 'sent', + payment_address: '0xpaymentaddr', + crypto_amount: '25.00000000', + fee_amount: 0.25, + ...(overrides.invoice ?? {}), + }), + paymentLink: 'paymentLink' in overrides + ? overrides.paymentLink + : `https://coinpayportal.com/now/${INVOICE_ID}`, + emailAttempted: 'emailAttempted' in overrides ? overrides.emailAttempted : false, + idempotentReplay: overrides.idempotentReplay ?? false, + }; +} + +describe('CoinPayClient.publishInvoice — contract', () => { + it('POSTs to the publish endpoint and verifies the sent invoice before returning', async () => { + let captured: Captured | undefined; + const c = client(mockFetch(200, publishResponse(), (x) => (captured = x))); + + const res = await c.publishInvoice(INVOICE_ID, { amountUsd: 25 }); + + expect(captured!.url).toBe(`https://coinpayportal.com/api/invoices/${INVOICE_ID}/publish`); + expect(captured!.method).toBe('POST'); + expect(captured!.headers['Authorization']).toBe('Bearer cp_live_test'); + expect(res).toMatchObject({ + invoiceId: INVOICE_ID, + invoiceNumber: 'INV-042', + status: 'sent', + paymentAddress: '0xpaymentaddr', + feeRate: 0.01, + feeAmountUsd: 0.25, + idempotentReplay: false, + }); + }); + + it('rejects a payment link on another deployment instead of guessing which one is live', async () => { + const c = client(mockFetch(200, publishResponse({ + paymentLink: `https://app.internal.coinpayportal.com/now/${INVOICE_ID}`, + }))); + await expect(c.publishInvoice(INVOICE_ID, { amountUsd: 25 })).rejects.toMatchObject({code: 'INVALID_RESPONSE'}); + }); + + it('computes the fee from the returned fee_rate when fee_amount is absent', async () => { + const c = client(mockFetch(200, publishResponse({ + invoice: { fee_amount: null, fee_rate: '0.013' }, + }))); + const res = await c.publishInvoice(INVOICE_ID, { amountUsd: 25 }); + expect(res.feeRate).toBe(0.013); + expect(res.feeAmountUsd).toBe(0.325); + }); + + it.each([ + [20.13, '0.01', 0.2013, 0.2013], + [20.13, '0.01', '0.20130000', 0.2013], + [1, '0.005', 0.005, 0.005], + [0.1, '0.005', '0.00050000', 0.0005], + ])('accepts the unrounded activation fee for a %s USD invoice', async (amount, rate, fee, expectedFee) => { + const c = client(mockFetch(200, publishResponse({ + invoice: { amount, fee_rate: rate, fee_amount: fee }, + }))); + const res = await c.publishInvoice(INVOICE_ID, { amountUsd: amount as number }); + expect(res.feeAmountUsd).toBe(expectedFee); + expect(res.paymentLink).toBe(`https://coinpayportal.com/now/${INVOICE_ID}`); + }); + + it.each([ + ['not sent', { invoice: { status: 'draft' } }], + ['missing payment address', { invoice: { payment_address: '' } }], + ['different invoice id', { invoice: { id: '3f9c1e00-0000-4000-8000-00000000bb02' } }], + ['payment link for another invoice', { paymentLink: 'https://coinpayportal.com/now/3f9c1e00-0000-4000-8000-00000000bb02' }], + ['missing payment link', { paymentLink: null }], + ['email attempted', { emailAttempted: true }], + ['missing fee rate', { invoice: { fee_rate: null } }], + ['boolean fee amount', { invoice: { fee_amount: false } }], + ['fee exceeds invoice', { invoice: { fee_amount: 26 } }], + ['negative fee amount', { invoice: { fee_amount: -0.005 } }], + ['nonfinite fee string', { invoice: { fee_amount: 'Infinity' } }], + ['invalid fee string', { invoice: { fee_amount: 'not-a-number' } }], + ['fractional invoice cent', { invoice: { amount: 25.001 } }], + ['tampered amount', { invoice: { amount: 2500 } }], + ])('refuses to return an unverified publish response: %s', async (_name, overrides) => { + const c = client(mockFetch(200, publishResponse(overrides as never))); + await expect(c.publishInvoice(INVOICE_ID, { amountUsd: 25 })).rejects.toMatchObject({ + code: 'INVALID_RESPONSE', + }); + }); + + it('never requests a non-UUID invoice id', async () => { + let called = false; + const c = client(mockFetch(200, publishResponse(), () => (called = true))); + await expect(c.publishInvoice('../mark-paid', { amountUsd: 25 })).rejects.toMatchObject({ + code: 'INVALID_RESPONSE', + }); + expect(called).toBe(false); + }); + + it.each([ + [409, { success: false, error: 'Invoice payment is still being created; retry shortly', code: 'PAYMENT_CREATION_IN_PROGRESS' }, 'PUBLISH_RETRY'], + [409, { success: false, error: 'Invoice changed while payment details were being created; refresh and retry', code: 'INVOICE_STATE_CHANGED' }, 'PUBLISH_RETRY'], + [409, { success: false, error: 'Invoice is sent but has no active payment details', code: 'PAYMENT_ADDRESS_MISSING' }, 'PUBLISH_RETRY'], + [400, { success: false, error: 'Cannot publish invoice with status: paid', code: 'INVOICE_NOT_PUBLISHABLE' }, 'NOT_PUBLISHABLE'], + ])('maps publish %s %j to %s', async (status, body, code) => { + const c = client(mockFetch(status as number, body)); + await expect(c.publishInvoice(INVOICE_ID, { amountUsd: 25 })).rejects.toMatchObject({ code }); + }); + + it('keeps raw API error text off the CoinPayError code path used for comments', async () => { + const c = client(mockFetch(503, { + success: false, + error: 'relation "invoice_creation_requests" does not exist', + code: 'IDEMPOTENCY_UNAVAILABLE', + })); + const err = await c.createInvoice(createInput()).catch((e: CoinPayError) => e); + expect(err).toBeInstanceOf(CoinPayError); + // The raw text is preserved for Action logs on the error object itself… + expect((err as CoinPayError).message).toContain('invoice_creation_requests'); + // …but the code is what the handler renders from (fixed strings only). + expect((err as CoinPayError).code).toBe('UNAVAILABLE'); + }); +}); diff --git a/test/main.test.ts b/test/main.test.ts new file mode 100644 index 0000000..71f869d --- /dev/null +++ b/test/main.test.ts @@ -0,0 +1,116 @@ +/** + * Action entrypoint wiring: event filtering (created-only), actor identity + * passthrough, resolved config defaults, and outputs. The handler and GitHub + * transports are mocked; nothing leaves the process. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const context = { + eventName: 'issue_comment', + payload: {} as Record, + repo: { owner: 'acme', repo: 'widgets' }, + }; + return { + context, + getInput: vi.fn((name: string) => { + const inputs: Record = { + 'github-token': 'gh-token', + 'coinpay-api-key': 'cp_live_test', + 'coinpay-business-id': 'biz_123', + }; + return inputs[name] ?? ''; + }), + info: vi.fn(), + setOutput: vi.fn(), + setFailed: vi.fn(), + handleComment: vi.fn(), + getOctokit: vi.fn(() => ({ + rest: { repos: { getContent: vi.fn().mockRejectedValue(Object.assign(new Error('Not Found'), { status: 404 })) } }, + })), + }; +}); + +vi.mock('@actions/core', () => ({ + getInput: mocks.getInput, + info: mocks.info, + setOutput: mocks.setOutput, + setFailed: mocks.setFailed, +})); +vi.mock('@actions/github', () => ({ + context: mocks.context, + getOctokit: mocks.getOctokit, +})); +vi.mock('../src/handler.js', () => ({ handleComment: mocks.handleComment })); + +async function runEntrypoint(): Promise { + vi.resetModules(); + const { run } = await import('../src/main.js'); + // The module self-invokes run() on import; awaiting our own call keeps the + // assertions deterministic. Call counts below account for both invocations. + await run(); +} + +describe('Action entrypoint', () => { + beforeEach(() => { + mocks.handleComment.mockReset(); + mocks.setOutput.mockReset(); + mocks.handleComment.mockResolvedValue({ action: 'invoice_published', invoiceId: 'inv-1' }); + }); + + it('ignores edited comments entirely', async () => { + mocks.context.payload = { + action: 'edited', + comment: { id: 9001, body: '/coinpay create @hubber 25 "x"', user: { id: 555, login: 'octocat', type: 'User' } }, + issue: { number: 42, html_url: 'https://github.com/acme/widgets/issues/42' }, + }; + + await runEntrypoint(); + + expect(mocks.handleComment).not.toHaveBeenCalled(); + }); + + it('ignores non-comment events', async () => { + mocks.context.payload = { action: 'created' }; + + await runEntrypoint(); + + expect(mocks.handleComment).not.toHaveBeenCalled(); + }); + + it('passes immutable actor identity and safe config defaults to the handler', async () => { + mocks.context.payload = { + action: 'created', + repository: {id: 1234}, + comment: { + id: 9001, + body: '/coinpay create @hubber 25 "x"', + author_association: 'NONE', + user: { id: 555, login: 'octocat', type: 'User' }, + }, + issue: { number: 42, html_url: 'https://github.com/acme/widgets/issues/42' }, + }; + + await runEntrypoint(); + + expect(mocks.handleComment).toHaveBeenCalled(); + const [evt, deps] = mocks.handleComment.mock.calls[0]!; + expect(evt).toMatchObject({ + ref: { owner: 'acme', repo: 'widgets', issueNumber: 42 }, + repositoryId: 1234, + commentId: 9001, + actor: 'octocat', + actorId: 555, + actorType: 'User', + isPullRequest: false, + }); + // Without a repo config file, the invoice flow must resolve disabled. + expect(deps.config.githubInvoices).toEqual({ + enabled: false, + maxAmountUsd: 1000, + repositoryHourlyCap: 20, + }); + expect(mocks.setOutput).toHaveBeenCalledWith('action', 'invoice_published'); + expect(mocks.setOutput).toHaveBeenCalledWith('invoice_id', 'inv-1'); + }); +}); diff --git a/test/parser.test.ts b/test/parser.test.ts index e6a430a..4f7fcc3 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -144,6 +144,155 @@ describe('parseCommand', () => { }); }); +describe('parseCommand — publish-invoice grammar (@payer first argument)', () => { + it('parses the canonical form', () => { + expect(parseCommand('/coinpay create @octocat 25 "Fix the settlement race"')).toEqual({ + kind: 'publish_invoice', + payer: 'octocat', + amount: 25, + fiat: 'USD', + description: 'Fix the settlement race', + dryRun: false, + }); + }); + + it('accepts the optional $ prefix, literal USD, and --dry-run', () => { + expect( + parseCommand('/coinpay create @octocat $10.50 USD "Milestone 1" --dry-run'), + ).toEqual({ + kind: 'publish_invoice', + payer: 'octocat', + amount: 10.5, + fiat: 'USD', + description: 'Milestone 1', + dryRun: true, + }); + }); + + it('keeps a numeric first argument on the legacy create flow', () => { + expect(parseCommand('/coinpay create $10 USD --wallet 0xabc')).toMatchObject({ + kind: 'invoice', + source: 'create', + amount: 10, + wallet: '0xabc', + }); + }); + + it.each([ + '@', + '@-octocat', + '@octocat-', + '@oct$cat', + '@' + 'a'.repeat(40), + ])('rejects an invalid payer login %s', (payer) => { + expect(parseCommand(`/coinpay create ${payer} 10 "x"`)).toMatchObject({ + kind: 'error', + code: 'bad_payer', + flow: 'publish_invoice', + }); + }); + + it.each(['-5', 'abc', '1.001', '1e3', '1000000000', '0'])( + 'rejects a non-canonical amount %s', + (amount) => { + expect(parseCommand(`/coinpay create @octocat ${amount} "x"`)).toMatchObject({ + kind: 'error', + code: 'bad_amount', + }); + }, + ); + + it('rejects non-USD fiat and missing pieces', () => { + expect(parseCommand('/coinpay create @octocat 10 EUR "x"')).toMatchObject({ + kind: 'error', + code: 'bad_fiat', + }); + expect(parseCommand('/coinpay create @octocat')).toMatchObject({ + kind: 'error', + code: 'missing_amount', + }); + expect(parseCommand('/coinpay create @octocat 10')).toMatchObject({ + kind: 'error', + code: 'missing_description', + }); + expect(parseCommand('/coinpay create @octocat 10 USD')).toMatchObject({ + kind: 'error', + code: 'missing_description', + }); + }); + + it('requires the description to be quoted', () => { + expect(parseCommand('/coinpay create @octocat 10 fix-bug')).toMatchObject({ + kind: 'error', + code: 'bad_description', + }); + }); + + it('treats a quoted "USD" as a description, not fiat', () => { + expect(parseCommand('/coinpay create @octocat 10 "USD"')).toMatchObject({ + kind: 'publish_invoice', + description: 'USD', + }); + }); + + it('rejects sneaked flags — wallet, crypto, anything but --dry-run', () => { + for (const flag of ['--wallet 0xattacker', '--crypto btc', '--for x', '--to y', '--client z']) { + expect(parseCommand(`/coinpay create @octocat 10 "x" ${flag}`)).toMatchObject({ + kind: 'error', + code: 'unknown_flag', + flow: 'publish_invoice', + }); + } + }); + + it('rejects trailing arguments after the description', () => { + expect(parseCommand('/coinpay create @octocat 10 "x" extra')).toMatchObject({ + kind: 'error', + code: 'bad_arguments', + }); + }); + + it('treats a quoted "--dry-run" as data, not a flag', () => { + expect(parseCommand('/coinpay create @octocat 10 "--dry-run"')).toEqual({ + kind: 'publish_invoice', + payer: 'octocat', + amount: 10, + fiat: 'USD', + description: '--dry-run', + dryRun: false, + }); + }); + + it('sanitizes the description deterministically: control chars and runs of space', () => { + expect( + parseCommand('/coinpay create @octocat 10 "a\tb\u0000c d"'), + ).toMatchObject({ kind: 'publish_invoice', description: 'a b c d' }); + }); + it.each([ + '/coinpay create @bad`@victim 10 "x"', + '/coinpay create @payer bad`@victim "x"', + '/coinpay create @payer 10 "x" --bad`@victim', + '/coinpay create @payer 10 "x" unexpected`@victim', + ])('does not echo hostile input in public parse errors: %s', (command) => { + const result = parseCommand(command); + expect(result).toMatchObject({kind: 'error', flow: 'publish_invoice'}); + if (result.kind === 'error') expect(result.message).not.toContain('@victim'); + }); + + it('bounds the description and rejects blank text', () => { + expect( + parseCommand(`/coinpay create @octocat 10 "${'a'.repeat(201)}"`), + ).toMatchObject({ kind: 'error', code: 'bad_description' }); + expect( + parseCommand(`/coinpay create @octocat 10 "${'a'.repeat(200)}"`), + ).toMatchObject({ kind: 'publish_invoice' }); + expect(parseCommand('/coinpay create @octocat 10 " "')).toMatchObject({ + kind: 'error', + code: 'bad_description', + }); + }); +}); + describe('isCanonicalUsdAmount', () => { it.each([0.01, 10, 10.5, 999_999_999.99])( 'accepts a canonical marker amount %s',