diff --git a/__tests__/message-handler.test.ts b/__tests__/message-handler.test.ts index d8a2d93..8fd527b 100644 --- a/__tests__/message-handler.test.ts +++ b/__tests__/message-handler.test.ts @@ -30,6 +30,7 @@ describe("onMessage", () => { "syncBuyback", "syncBuybackDetail", "syncBuybackPricing", + "syncStoreCredit", "verify-write", "verify-remove", "org-verify-write", @@ -304,6 +305,74 @@ describe("onMessage", () => { expect(result.code).toBe(502); }); + it("reads the store credit in USD from RSI's account query", async () => { + const sendResponse = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + data: { + customer: { + ledgerCredit: { + amount: { value: 84956, currency: { code: "USD" } }, + }, + }, + }, + }), + { status: 200 } + ) + ); + + await onMessage( + JSON.stringify({ action: "syncStoreCredit" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result).toEqual({ + code: 200, + action: "syncStoreCredit", + payload: { credit: 849.56 }, + }); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe("https://robertsspaceindustries.com/graphql"); + expect(JSON.parse(String(init?.body)).operationName).toBe("credit"); + }); + + it("reads no store credit RSI answers without an amount", async () => { + const sendResponse = vi.fn(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ data: { customer: null } }), { status: 200 }) + ); + + await onMessage( + JSON.stringify({ action: "syncStoreCredit" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(502); + }); + + it("asks for no store credit without an RSI session", async () => { + const sendResponse = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await onMessage( + JSON.stringify({ action: "syncStoreCredit" }), + sendResponse, + vi.fn().mockResolvedValue(null), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(401); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("responds to syncBuyback action without token", async () => { const sendResponse = vi.fn(); const getToken = vi.fn().mockResolvedValue(null); diff --git a/lib/message-handler.ts b/lib/message-handler.ts index 4d0f903..da8dfdf 100644 --- a/lib/message-handler.ts +++ b/lib/message-handler.ts @@ -5,6 +5,7 @@ import { fetchBuybackDetail, setStoreAuthToken, fetchStorePricing, + fetchStoreCredit, fetchCitizenPage, updateBio, fetchOrgAdminPage, @@ -39,6 +40,7 @@ export const SUPPORTED_ACTIONS = [ "syncBuyback", "syncBuybackDetail", "syncBuybackPricing", + "syncStoreCredit", "verify-write", "verify-remove", "org-verify-write", @@ -163,6 +165,24 @@ async function storePricing(token: string) { }; } +// The store credit on the account, in USD, as RSI's account panel shows it. +// RSI keeps it in cents, and only in USD. +async function storeCredit(token: string) { + const response = await fetchStoreCredit(token); + if (!response.ok) { + return { code: response.status, error: "Store credit failed" }; + } + + const body: any = await response.json().catch(() => undefined); + const amount = body?.data?.customer?.ledgerCredit?.amount; + const cents = amount?.value; + if (!Number.isInteger(cents) || cents < 0 || amount?.currency?.code !== "USD") { + return { code: 502, error: "Store credit unreadable" }; + } + + return { code: 200, payload: { credit: cents / 100 } }; +} + // Requests for the same org (or the same bio) wait for each other: a remove // sent while a slow write is still out would read the draft before the write // lands, find nothing, and leave the token the write then publishes. @@ -455,6 +475,22 @@ export async function onMessage( return { code: 500, error: "Store pricing failed" }; }); + sendResponse(JSON.stringify({ action: message.action, ...result })); + } + } else if (message?.action == "syncStoreCredit") { + const token = await getToken(); + + if (!token) { + sendResponse( + JSON.stringify({ code: 401, action: message.action, error: "No RSI session" }) + ); + } else { + const result = await storeCredit(token).catch((error) => { + console.error("FY Sync: Store credit failed", error); + + return { code: 500, error: "Store credit failed" }; + }); + sendResponse(JSON.stringify({ action: message.action, ...result })); } } else if (message?.action == "sync" || message?.action == "syncBuyback") { diff --git a/lib/rsi.ts b/lib/rsi.ts index 5851bff..fdfcf87 100644 --- a/lib/rsi.ts +++ b/lib/rsi.ts @@ -93,6 +93,21 @@ export function fetchStorePricing(token: string) { }); } +// The balances RSI's account panel shows, from the same GraphQL query: store +// credit in USD cents. +export function fetchStoreCredit(token: string) { + return fetchRSIApi({ + url: `${RSI_BASE_URL}/graphql`, + payload: { + operationName: "credit", + variables: {}, + query: + "query credit { customer { ledgerCredit: ledger(ledgerCode: credit) { amount { value currency { code } } } } }", + }, + rsiToken: token, + }); +} + export function fetchCitizenPage(handle: string) { return fetch(`${RSI_BASE_URL}/en/citizens/${encodeURIComponent(handle)}`, { method: "GET",