Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions __tests__/message-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe("onMessage", () => {
"syncBuyback",
"syncBuybackDetail",
"syncBuybackPricing",
"syncStoreCredit",
"verify-write",
"verify-remove",
"org-verify-write",
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 36 additions & 0 deletions lib/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
fetchBuybackDetail,
setStoreAuthToken,
fetchStorePricing,
fetchStoreCredit,
fetchCitizenPage,
updateBio,
fetchOrgAdminPage,
Expand Down Expand Up @@ -39,6 +40,7 @@ export const SUPPORTED_ACTIONS = [
"syncBuyback",
"syncBuybackDetail",
"syncBuybackPricing",
"syncStoreCredit",
"verify-write",
"verify-remove",
"org-verify-write",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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") {
Expand Down
15 changes: 15 additions & 0 deletions lib/rsi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading