commit 6605b528aad05bc7e58ec4d2dca47c8300522e5a
parent 4937b40a71699a21006eeb08be36e177ec7a4a71
Author: Florian Dold <dold@taler.net>
Date: Tue, 25 Aug 2026 23:10:08 +0200
merchant web UI: distinguish paid and settled order rows
Diffstat:
5 files changed, 63 insertions(+), 13 deletions(-)
diff --git a/packages/taler-merchant-webui/src/api/hooks.test.ts b/packages/taler-merchant-webui/src/api/hooks.test.ts
@@ -50,8 +50,8 @@ test("isNonZeroAmount identifies zero and non-zero string and object amounts", (
// A list entry and a status response are different shapes, and `mapOrderStatus`
// runs over both. These fixtures are the real ones: every required field is
// present, and none carries a field its endpoint does not send. Older fixtures
-// were list entries wearing `wired`, `order_status` and a `pay_deadline` of
-// `{t_ms}`, so they tested a response no backend produces.
+// were list entries wearing `order_status` and a `pay_deadline` of `{t_ms}`,
+// so they tested a response no backend produces.
function listEntry(
over: Partial<TalerMerchantApi.OrderHistoryEntry>,
): TalerMerchantApi.OrderHistoryEntry {
@@ -90,10 +90,22 @@ test("a list entry with a refund of any kind reads as refunded", () => {
);
});
-test("a list entry cannot say the money was wired, so the chosen filter does", () => {
- // `OrderHistoryEntry` has no `wired` field. Asked for settled orders, the
- // backend answered with settled orders, and that is the only evidence there
- // is; the row would otherwise read "paid" forever.
+test("a list entry uses its wired value before the chosen filter", () => {
+ assert.strictEqual(
+ mapOrderStatus(listEntry({ paid: true, wired: true }), "all"),
+ "settled",
+ );
+ assert.strictEqual(
+ mapOrderStatus(listEntry({ paid: true, wired: true }), "paid"),
+ "settled",
+ );
+ assert.strictEqual(
+ mapOrderStatus(listEntry({ paid: true, wired: false }), "settled"),
+ "paid",
+ );
+
+ // A backend older than protocol v38 omits `wired`. In that case the
+ // selected settled filter remains the only available evidence.
assert.strictEqual(mapOrderStatus(listEntry({ paid: true }), "settled"), "settled");
assert.strictEqual(mapOrderStatus(listEntry({ paid: true }), "all"), "paid");
});
diff --git a/packages/taler-merchant-webui/src/api/hooks/useOrders.ts b/packages/taler-merchant-webui/src/api/hooks/useOrders.ts
@@ -114,7 +114,8 @@ export function mapOrderStatus(
if (isRefunded) return "refunded";
- const isSettled = queryStatus === "settled" || ("wired" in o && o.wired === true);
+ const hasWiredStatus = "wired" in o && o.wired !== undefined;
+ const isSettled = hasWiredStatus ? o.wired === true : queryStatus === "settled";
if (isSettled) return "settled";
diff --git a/packages/taler-merchant-webui/src/tutorial/tutorialData.tsx b/packages/taler-merchant-webui/src/tutorial/tutorialData.tsx
@@ -1237,7 +1237,7 @@ function buildTutorialChapters(t: TranslateFn): TutorialChapter[] {
menuEntry: "Orders",
keyTakeaways: [
t`The list updates itself — you do not need to reload it to see a payment land.`,
- t`The tabs sort orders by where they have got to: Offered, Paid, Refunded, Settled.`,
+ t`The tabs are filters: they group orders by payment, refund, payout, and expiration facts.`,
t`You can refund an order in full or in part, as long as its refund window is still open.`,
t`A refund the customer never collects does lapse. The order says so plainly when it does.`,
],
@@ -1245,18 +1245,23 @@ function buildTutorialChapters(t: TranslateFn): TutorialChapter[] {
{
title: t`The Order List`,
blocks: [
- { kind: "p", text: t`Each row reads left to right as when, what, how much, and where it has got to. The tabs across the top narrow the list down:` },
+ { kind: "p", text: t`Each row reads left to right as when, what, how much, and where it has got to. The tabs across the top apply these filters:` },
{ kind: "list", items: [
- t`**Offered** — you have asked for the money; nobody has paid yet.`,
+ t`**All** — every order, without a status filter.`,
- t`**Paid** — the customer has paid. The money is on its way to you but has not arrived.`,
+ t`**Offered** — unpaid orders whose payment deadline has not passed.`,
- t`**Settled** — your payment service has sent the money on to your bank. Whether it has landed is a separate question, and the Bank accounts screen is where you answer it.`,
+ t`**Paid** — paid orders that have no refund and whose payout has not yet been reported by the exchange.`,
- t`**Refunded** — you have given some or all of it back.`,
+ t`**Refunded** — orders with a partial or full refund, whether or not their payout was already sent.`,
+
+ t`**Settled** — orders with no refund whose exchange reports that it sent the payout to your bank. This does not confirm that your bank received it.`,
+
+ t`**Expired** — unpaid orders whose payment deadline has passed.`,
] },
+ { kind: "p", text: t`These are overlapping database filters rather than a single sequence of names. For example, the Refunded tab includes refunded orders on either side of payout, while the Paid and Settled tabs deliberately exclude them.` },
{ kind: "p", text: t`Use the **Data** menu in the window bar to see the list before your first sale.` },
],
dataSets: [
@@ -1301,6 +1306,8 @@ function buildTutorialChapters(t: TranslateFn): TutorialChapter[] {
previewMaxHeightClass: "max-h-none",
blocks: [
{ kind: "p", text: t`Opening an order shows its current state and total first. The essential dates follow in a short list; open **Order history** when you need the full sequence of what happened and when: created, paid, refunded, paid out.` },
+ { kind: "p", text: t`The details page can be more specific than the list filters. It may say **Awaiting payment**, **Wallet completing payment**, **Expired unpaid**, **Paid, awaiting payout**, **Settled**, **Refund awaiting collection**, **Partially refunded**, **Fully refunded**, **Refund lapsed**, or simply **Refunded**.` },
+ { kind: "p", text: t`In the backend protocol, **order_status** remains **paid** after an accepted payment even when its payout has been sent. The separate **wired** value is what changes the portal's display from **Paid, awaiting payout** to **Settled**. Settled means the exchange reports that it sent the proceeds; confirming arrival at your bank is a separate step.` },
{ kind: "p", text: t`The **refund window** is worth knowing about. It is how long you can still refund the order, and once it closes you cannot — you would have to return the money another way.` },
],
dataSets: [
diff --git a/packages/taler-util/src/types-taler-merchant.test.ts b/packages/taler-util/src/types-taler-merchant.test.ts
@@ -20,9 +20,34 @@ import {
codecForLoginTokenSuccessResponse,
codecForPaymentDeniedLegallyResponse,
codecForQueryInstancesResponse,
+ codecForOrderHistoryEntry,
codecForTokenFamilyDetails,
} from "./types-taler-merchant.js";
+function orderHistoryEntry(): Record<string, unknown> {
+ return {
+ order_id: "2026.237-TEST",
+ row_id: 42,
+ timestamp: { t_s: 1_770_000_000 },
+ pay_deadline: { t_s: 1_770_003_600 },
+ amount: "KUDOS:10",
+ summary: "An order",
+ refundable: false,
+ paid: true,
+ };
+}
+
+test("order history entries decode v38 wired status and legacy omission", () => {
+ const legacy = codecForOrderHistoryEntry().decode(orderHistoryEntry());
+ assert.strictEqual(legacy.wired, undefined);
+
+ const current = codecForOrderHistoryEntry().decode({
+ ...orderHistoryEntry(),
+ wired: true,
+ });
+ assert.strictEqual(current.wired, true);
+});
+
test("legal payment refusal retains its protocol error code", () => {
assert.deepStrictEqual(
codecForPaymentDeniedLegallyResponse().decode({
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -2972,6 +2972,10 @@ export interface OrderHistoryEntry {
// Whether the order has been paid or not.
paid: boolean;
+
+ // Whether the exchange reports that it wired the order's funds.
+ // Optional for compatibility with backends older than protocol v38.
+ wired?: boolean;
}
export type MerchantOrderStatusResponse =
@@ -5077,6 +5081,7 @@ export const codecForOrderHistoryEntry = (): Codec<OrderHistoryEntry> =>
.property("summary", codecForString())
.property("refundable", codecForBoolean())
.property("paid", codecForBoolean())
+ .property("wired", codecOptional(codecForBoolean()))
.build("TalerMerchantApi.OrderHistoryEntry");
export const codecForMerchant = (): Codec<Merchant> =>