diff --git a/src/app/settings/connections/page.tsx b/src/app/settings/connections/page.tsx
index 5f3edd91..2ee0a4fd 100644
--- a/src/app/settings/connections/page.tsx
+++ b/src/app/settings/connections/page.tsx
@@ -4,7 +4,8 @@ import { Header } from "@/components/layout/Header";
import { createClient } from "@/lib/supabase/server";
import { createServiceClient } from "@/lib/supabase/service";
import { buttonVariants } from "@/components/ui/button";
-import { CheckCircle2, ExternalLink, LinkIcon, RefreshCw } from "lucide-react";
+import { AlertTriangle, CheckCircle2, ExternalLink, LinkIcon, RefreshCw } from "lucide-react";
+import { coinpayLinkCanReadWallets } from "@/lib/coinpay-oauth";
import { DisconnectCoinpayButton } from "./DisconnectCoinpayButton";
export const metadata = {
@@ -58,6 +59,10 @@ export default async function OAuthConnectionsPage({
const connectedAt =
typeof metadata.connected_at === "string" ? metadata.connected_at : coinpayIdentity?.updated_at || null;
const tokenExpiresAt = typeof metadata.expires_at === "string" ? metadata.expires_at : null;
+ // A link whose token lacks wallet:read exists but cannot do the one job this
+ // connection is for. Say so here rather than letting the invoice form be the
+ // first place the user finds out.
+ const needsReconnect = Boolean(coinpayIdentity) && !coinpayLinkCanReadWallets(metadata);
const params = await searchParams;
const message = statusMessage(params.coinpay, params.linked_to);
@@ -83,12 +88,18 @@ export default async function OAuthConnectionsPage({
CoinPay
- {coinpayIdentity && (
+ {coinpayIdentity && !needsReconnect && (
Connected
)}
+ {needsReconnect && (
+
+
+ Reconnect required
+
+ )}
Connect your CoinPay account so ugig can read your CoinPay global wallet addresses
@@ -112,6 +123,14 @@ export default async function OAuthConnectionsPage({
+ {needsReconnect && (
+
+ This CoinPay account is linked, but the link was granted without permission to
+ read your wallet addresses, so ugig cannot send invoices with it. Click
+ Reconnect above to re-authorize — nothing else on your account changes.
+
+ )}
+
{coinpayIdentity ? (
diff --git a/src/lib/coinpay-oauth.test.ts b/src/lib/coinpay-oauth.test.ts
new file mode 100644
index 00000000..6b7ab060
--- /dev/null
+++ b/src/lib/coinpay-oauth.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { coinpayLinkCanReadWallets } from "./coinpay-oauth";
+
+// The connections page and the invoice gate must agree on what a usable CoinPay
+// link is. They did not: any oauth_identities row rendered as "Connected", while
+// invoicing rejected a token without wallet:read. A worker saw a green
+// "Connected" badge and "Connect your CoinPay account before sending an invoice"
+// from the same account, and reconnecting could not fix it — the ugig.net OAuth
+// client was registered without wallet:read, so CoinPay filtered the scope out
+// of every grant.
+describe("coinpayLinkCanReadWallets", () => {
+ it("accepts a token granted wallet:read", () => {
+ expect(
+ coinpayLinkCanReadWallets({
+ access_token: "tok",
+ scope: "openid profile email wallet:read",
+ })
+ ).toBe(true);
+ });
+
+ it("rejects a token granted without wallet:read", () => {
+ expect(
+ coinpayLinkCanReadWallets({ access_token: "tok", scope: "openid profile email" })
+ ).toBe(false);
+ });
+
+ it("rejects a link with no recorded scope", () => {
+ expect(coinpayLinkCanReadWallets({ access_token: "tok", scope: null })).toBe(false);
+ });
+
+ it("rejects a link with no access token", () => {
+ expect(coinpayLinkCanReadWallets({ scope: "openid wallet:read" })).toBe(false);
+ });
+
+ it("rejects a blank access token", () => {
+ expect(
+ coinpayLinkCanReadWallets({ access_token: " ", scope: "openid wallet:read" })
+ ).toBe(false);
+ });
+
+ it("does not match wallet:read as a substring of another scope", () => {
+ expect(
+ coinpayLinkCanReadWallets({ access_token: "tok", scope: "openid wallet:readwrite" })
+ ).toBe(false);
+ });
+
+ it("treats a missing or non-object metadata as unusable", () => {
+ expect(coinpayLinkCanReadWallets(null)).toBe(false);
+ expect(coinpayLinkCanReadWallets(undefined)).toBe(false);
+ expect(coinpayLinkCanReadWallets("openid wallet:read")).toBe(false);
+ });
+});
diff --git a/src/lib/coinpay-oauth.ts b/src/lib/coinpay-oauth.ts
index c68fde28..fa28af27 100644
--- a/src/lib/coinpay-oauth.ts
+++ b/src/lib/coinpay-oauth.ts
@@ -4,7 +4,25 @@ function metadataObject(value: unknown): Record {
return value && typeof value === "object" ? (value as Record) : {};
}
-const REQUIRED_COINPAY_SCOPE = "wallet:read";
+export const REQUIRED_COINPAY_SCOPE = "wallet:read";
+
+/**
+ * Does this stored CoinPay link carry the scope we need to read wallets?
+ *
+ * The connection UI and the invoice gate have to answer this the same way. They
+ * did not: the UI called any `oauth_identities` row "Connected", while invoicing
+ * additionally required `wallet:read` and rejected the link without it. A user
+ * whose token lacked the scope saw a green "Connected" badge and
+ * "Connect your CoinPay account before sending an invoice" from the same
+ * account, with no way to reconcile the two.
+ */
+export function coinpayLinkCanReadWallets(metadata: unknown): boolean {
+ const meta = metadataObject(metadata);
+ const accessToken = typeof meta.access_token === "string" ? meta.access_token.trim() : "";
+ if (!accessToken) return false;
+ const scope = typeof meta.scope === "string" ? meta.scope : "";
+ return scope.split(/\s+/).filter(Boolean).includes(REQUIRED_COINPAY_SCOPE);
+}
const TOKEN_URL = "https://coinpayportal.com/api/oauth/token";
// Refresh if token expires within 5 minutes
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
@@ -80,14 +98,11 @@ export async function getConnectedCoinpayAccessToken(userId: string): Promise