@alexkroman1/aai-cli 6.11.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
- package/dist/_templates.d.ts +3 -4
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
- package/dist/cli.mjs +346 -349
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +442 -80
- package/dist/scaffold/package.json +6 -3
- package/dist/scaffold/server.mjs +11 -4
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
- package/dist/templates/briefing-desk/agent.test.ts +350 -0
- package/dist/templates/briefing-desk/agent.ts +30 -0
- package/dist/templates/briefing-desk/shared.ts +200 -0
- package/dist/templates/briefing-desk/system-prompt.md +39 -0
- package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
- package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
- package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
- package/dist/templates/call-audit/agent.eval.test.ts +474 -0
- package/dist/templates/call-audit/agent.test.ts +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
- package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +53 -24
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/infocom-adventure/shared.ts +20 -9
- package/dist/templates/infocom-adventure/system-prompt.md +1 -1
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
- package/dist/templates/link-digest/agent.eval.test.ts +195 -0
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
- package/dist/templates/night-owl/agent.eval.test.ts +272 -0
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
- package/dist/templates/personal-finance/system-prompt.md +4 -0
- package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
- package/dist/templates/pipeline-simple/agent.ts +2 -2
- package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
- package/dist/templates/pizza-ordering/agent.test.ts +42 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/pizza-ordering/shared.ts +20 -1
- package/dist/templates/pizza-ordering/system-prompt.md +6 -0
- package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +717 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
- package/dist/templates/recap-workflow/agent.test.ts +97 -94
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
- package/dist/templates/redline/agent.eval.test.ts +209 -0
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/address.ts +87 -1
- package/dist/templates/retail/agent.eval.test.ts +314 -0
- package/dist/templates/retail/agent.test.ts +195 -112
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +265 -117
- package/dist/templates/retail/payment.ts +107 -0
- package/dist/templates/retail/pending.test.ts +196 -0
- package/dist/templates/retail/pending.ts +137 -0
- package/dist/templates/retail/registry.test.ts +150 -7
- package/dist/templates/retail/returns.ts +102 -0
- package/dist/templates/retail/shared.test.ts +42 -0
- package/dist/templates/retail/shared.ts +35 -0
- package/dist/templates/retail/store.test.ts +84 -15
- package/dist/templates/retail/store.ts +231 -48
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +69 -28
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
- package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
- package/dist/templates/retail/tools/modify_user_address.ts +13 -16
- package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
- package/dist/templates/spoken-summary/agent.test.ts +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- package/dist/templates/support-line/agent.eval.test.ts +273 -0
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +66 -4
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- package/dist/templates/transcription-workflow/agent.test.ts +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
- package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +174 -43
- package/dist/templates/travel-concierge/agent.ts +18 -9
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +180 -12
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
- package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
- package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
- package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
- package/dist/templates/web-researcher/system-prompt.md +15 -4
- package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
- package/dist/test.d.ts +29 -5
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/eject-31gjtaHF.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cancelling a pending order, as a PLAN and an APPLY.
|
|
3
|
+
*
|
|
4
|
+
* The split is what the confirmation gate is built out of, and it is the same
|
|
5
|
+
* split in all seven mutating actions here: `plan*` validates everything and
|
|
6
|
+
* computes the effect without touching the store, `apply*` performs it and
|
|
7
|
+
* cannot fail. A staged plan is what the caller says yes to, so the validation
|
|
8
|
+
* has to happen BEFORE the readback — a "yes" followed by a refusal is the one
|
|
9
|
+
* sequence this gate exists to make impossible.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
|
|
13
|
+
import { creditRefund, REFUND_DELAY_NOTE, REFUND_IMMEDIATE_NOTE } from "./refund.ts";
|
|
14
|
+
import { resolveOrder } from "./resolve.ts";
|
|
15
|
+
import type { RetailState } from "./shared.ts";
|
|
16
|
+
import { authenticatedUser, money } from "./store.ts";
|
|
17
|
+
|
|
18
|
+
/** The only two reasons the store records. */
|
|
19
|
+
export const CANCEL_REASONS = ["no longer needed", "ordered by mistake"] as const;
|
|
20
|
+
export type CancelReason = (typeof CANCEL_REASONS)[number];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A validated cancellation, holding only storable primitives.
|
|
24
|
+
*
|
|
25
|
+
* No `Order` or `PaymentMethod` reference: a plan is written into the session
|
|
26
|
+
* slot and rides across turns, so a live object here would alias the store and
|
|
27
|
+
* a persisted session could not carry it. Ids and amounts are enough, and
|
|
28
|
+
* looking them up again at apply time is what keeps {@link applyCancel}
|
|
29
|
+
* total.
|
|
30
|
+
*/
|
|
31
|
+
export interface CancelPlan {
|
|
32
|
+
/** The sentence the agent reads back before asking for a yes. */
|
|
33
|
+
readBack: string;
|
|
34
|
+
orderId: string;
|
|
35
|
+
reason: CancelReason;
|
|
36
|
+
refunds: { methodId: string; amount: number }[];
|
|
37
|
+
total: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function planCancel(
|
|
41
|
+
state: RetailState,
|
|
42
|
+
spokenOrderId: string,
|
|
43
|
+
reason: CancelReason,
|
|
44
|
+
): CancelPlan | ToolFailure {
|
|
45
|
+
const user = authenticatedUser(state);
|
|
46
|
+
if (isToolFailure(user)) return user;
|
|
47
|
+
|
|
48
|
+
const order = resolveOrder(state, spokenOrderId);
|
|
49
|
+
if (isToolFailure(order)) return order;
|
|
50
|
+
|
|
51
|
+
// The enum makes this unreachable from a well-formed call; it stays because
|
|
52
|
+
// the reason is the one field a caller supplies in free speech, and an LLM
|
|
53
|
+
// tool call arrives untyped.
|
|
54
|
+
if (!CANCEL_REASONS.includes(reason)) {
|
|
55
|
+
return {
|
|
56
|
+
error: `'${reason}' is not an accepted cancellation reason. It must be 'no longer needed' or 'ordered by mistake'.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Exactly 'pending'. A 'pending (item modified)' order has spent its one
|
|
61
|
+
// modification and is past cancelling.
|
|
62
|
+
if (order.status !== "pending") {
|
|
63
|
+
return {
|
|
64
|
+
error: `Order ${order.order_id} is ${order.status}, and only a pending order can be cancelled.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const refunds = order.payment_history
|
|
69
|
+
.filter((payment) => payment.transaction_type === "payment")
|
|
70
|
+
.map((payment) => ({ methodId: payment.payment_method_id, amount: payment.amount }));
|
|
71
|
+
const total = money(refunds.reduce((sum, refund) => sum + refund.amount, 0));
|
|
72
|
+
|
|
73
|
+
const items = order.items.map((item) => item.name).join(", ");
|
|
74
|
+
const destinations = [...new Set(refunds.map((refund) => refund.methodId))].join(" and ");
|
|
75
|
+
return {
|
|
76
|
+
readBack:
|
|
77
|
+
`cancel order ${order.order_id} (${items}) as '${reason}' and refund ` +
|
|
78
|
+
`$${total.toFixed(2)} to ${destinations}`,
|
|
79
|
+
orderId: order.order_id,
|
|
80
|
+
reason,
|
|
81
|
+
refunds,
|
|
82
|
+
total,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The effect. Total by construction: every id in the plan was resolved from
|
|
87
|
+
* this same store, and nothing between staging and confirming can remove one. */
|
|
88
|
+
export function applyCancel(state: RetailState, plan: CancelPlan) {
|
|
89
|
+
const order = state.store.orders[plan.orderId];
|
|
90
|
+
const user = order ? state.store.users[order.user_id] : undefined;
|
|
91
|
+
let immediate = false;
|
|
92
|
+
|
|
93
|
+
if (order && user) {
|
|
94
|
+
for (const refund of plan.refunds) {
|
|
95
|
+
immediate = creditRefund(user, refund.methodId, refund.amount).immediate || immediate;
|
|
96
|
+
order.payment_history.push({
|
|
97
|
+
transaction_type: "refund",
|
|
98
|
+
amount: refund.amount,
|
|
99
|
+
payment_method_id: refund.methodId,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
order.status = "cancelled";
|
|
103
|
+
order.cancel_reason = plan.reason;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
order_id: plan.orderId,
|
|
108
|
+
status: "cancelled" as const,
|
|
109
|
+
cancel_reason: plan.reason,
|
|
110
|
+
refunded: plan.total,
|
|
111
|
+
refund_immediate: immediate,
|
|
112
|
+
message: `Order ${plan.orderId} is cancelled and $${plan.total.toFixed(2)} is being refunded. ${
|
|
113
|
+
immediate ? REFUND_IMMEDIATE_NOTE : REFUND_DELAY_NOTE
|
|
114
|
+
}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
|
-
import type { AgentState,
|
|
2
|
+
import type { AgentState, ConversationItem } from "@alexkroman1/aai-ui";
|
|
3
3
|
import {
|
|
4
4
|
AutoScroll,
|
|
5
5
|
client,
|
|
6
6
|
useAgentState,
|
|
7
|
+
useConversation,
|
|
7
8
|
useSession,
|
|
8
|
-
|
|
9
|
+
useSessionSelector,
|
|
9
10
|
} from "@alexkroman1/aai-ui";
|
|
10
11
|
import type { ReactNode } from "react";
|
|
11
12
|
import type {
|
|
12
13
|
OrderStatus,
|
|
13
14
|
OrderView,
|
|
14
15
|
PaymentMethodView,
|
|
16
|
+
PendingView,
|
|
15
17
|
StoreView,
|
|
16
18
|
SwapOptionView,
|
|
17
19
|
} from "./shared.ts";
|
|
@@ -154,6 +156,42 @@ function OrderCard({ order, focused }: { order: OrderView; focused: boolean }) {
|
|
|
154
156
|
|
|
155
157
|
/** Character select. Rendered before authentication, because a user with no
|
|
156
158
|
* email to give cannot start the conversation at all. */
|
|
159
|
+
/**
|
|
160
|
+
* The change waiting on the caller's word.
|
|
161
|
+
*
|
|
162
|
+
* The one panel that renders BECAUSE nothing has happened. A staged change is
|
|
163
|
+
* the agent's promise about what it is going to do, so showing it beside the
|
|
164
|
+
* orders it has not touched is what lets a watcher catch a readback that does
|
|
165
|
+
* not match the request — which is the failure the whole gate is aimed at, and
|
|
166
|
+
* the one nobody can see from a transcript alone.
|
|
167
|
+
*/
|
|
168
|
+
function PendingChange({ pending }: { pending: PendingView }) {
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
className="rounded-lg p-3"
|
|
172
|
+
style={{
|
|
173
|
+
background: "#fffbeb",
|
|
174
|
+
border: "1px solid #fcd34d",
|
|
175
|
+
animation: "rt-slide-in 180ms ease-out",
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<div
|
|
179
|
+
className="text-[10px] font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1.5"
|
|
180
|
+
style={{ color: "#b45309" }}
|
|
181
|
+
>
|
|
182
|
+
<span style={{ animation: "rt-pulse 1.4s ease-in-out infinite" }}>●</span>
|
|
183
|
+
Awaiting the caller's yes
|
|
184
|
+
</div>
|
|
185
|
+
<div className="text-xs leading-relaxed" style={{ color: "#78350f" }}>
|
|
186
|
+
{pending.readBack}
|
|
187
|
+
</div>
|
|
188
|
+
<div className="text-[10px] mt-1.5" style={{ color: "#a16207" }}>
|
|
189
|
+
Nothing has changed yet — {pending.kind}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
157
195
|
function PersonaList() {
|
|
158
196
|
return (
|
|
159
197
|
<div className="flex flex-col gap-2">
|
|
@@ -217,13 +255,226 @@ function SwapOptions({ option }: { option: SwapOptionView }) {
|
|
|
217
255
|
);
|
|
218
256
|
}
|
|
219
257
|
|
|
220
|
-
|
|
258
|
+
/** One line of the call: a bubble, or the tool the agent ran after it. */
|
|
259
|
+
function Row({ item }: { item: ConversationItem }) {
|
|
260
|
+
if (item.kind === "tool") {
|
|
261
|
+
const { name, status } = item.toolCall;
|
|
262
|
+
return (
|
|
263
|
+
<div
|
|
264
|
+
className="self-start rounded-md px-2.5 py-1 text-[11px] font-mono flex items-center gap-2"
|
|
265
|
+
style={{
|
|
266
|
+
background: "#ffffff",
|
|
267
|
+
border: "1px solid #e4e4e7",
|
|
268
|
+
color: status === "pending" ? "#b45309" : "#71717a",
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
<span
|
|
272
|
+
className="w-1.5 h-1.5 rounded-full inline-block"
|
|
273
|
+
style={{
|
|
274
|
+
background: status === "pending" ? "#ca8a04" : "#16a34a",
|
|
275
|
+
animation: status === "pending" ? "rt-pulse 1s ease-in-out infinite" : "none",
|
|
276
|
+
}}
|
|
277
|
+
/>
|
|
278
|
+
{name}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const { role, content } = item.message;
|
|
283
|
+
return (
|
|
284
|
+
<div
|
|
285
|
+
className="rounded-lg text-[13px] max-w-[80%] px-3.5 py-2.5"
|
|
286
|
+
style={{
|
|
287
|
+
lineHeight: 1.55,
|
|
288
|
+
alignSelf: role === "assistant" ? "flex-start" : "flex-end",
|
|
289
|
+
background: role === "assistant" ? "#ffffff" : "#2563eb",
|
|
290
|
+
color: role === "assistant" ? "#18181b" : "#ffffff",
|
|
291
|
+
border: role === "assistant" ? "1px solid #e4e4e7" : "none",
|
|
292
|
+
animation: "rt-slide-in 0.2s ease-out",
|
|
293
|
+
}}
|
|
294
|
+
>
|
|
295
|
+
{content}
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The call transcript.
|
|
302
|
+
*
|
|
303
|
+
* `useConversation()` rather than `session.messages.map(...)`. The messages
|
|
304
|
+
* were only part of it: this agent runs FIFTEEN tools and the operator could
|
|
305
|
+
* see none of them, because tool calls live in a second array this page never
|
|
306
|
+
* read — and the streaming reply and the thinking indicator were dropped along
|
|
307
|
+
* with them. The hook owns the interleave (a tool row follows the message it
|
|
308
|
+
* was anchored to), the `null`-vs-`""` transcript distinction and the
|
|
309
|
+
* thinking-suppression rule; the markup below is what this template is for.
|
|
310
|
+
*
|
|
311
|
+
* It subscribes per FIELD, so the customer file in the sidebar no longer
|
|
312
|
+
* re-renders on every partial transcript the way the whole-page `useSession()`
|
|
313
|
+
* this replaced made it.
|
|
314
|
+
*/
|
|
315
|
+
function Conversation() {
|
|
316
|
+
const { items, streaming, transcript, thinking } = useConversation();
|
|
317
|
+
return (
|
|
318
|
+
<>
|
|
319
|
+
<AutoScroll
|
|
320
|
+
scrollClassName="rt-scroll overflow-y-auto"
|
|
321
|
+
contentClassName="p-4 flex flex-col gap-2"
|
|
322
|
+
>
|
|
323
|
+
{items.length === 0 && streaming === null && (
|
|
324
|
+
<div className="text-center p-10 text-[13px]" style={{ color: "#a1a1aa" }}>
|
|
325
|
+
Press start, then read one of the emails from “Who to be” to the agent.
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
{items.map((item) => (
|
|
329
|
+
<Row
|
|
330
|
+
key={item.kind === "message" ? `m${item.message.id}` : item.toolCall.callId}
|
|
331
|
+
item={item}
|
|
332
|
+
/>
|
|
333
|
+
))}
|
|
334
|
+
{streaming !== null && (
|
|
335
|
+
<div
|
|
336
|
+
className="rounded-lg text-[13px] max-w-[80%] px-3.5 py-2.5 self-start"
|
|
337
|
+
style={{
|
|
338
|
+
lineHeight: 1.55,
|
|
339
|
+
background: "#ffffff",
|
|
340
|
+
color: "#18181b",
|
|
341
|
+
border: "1px solid #e4e4e7",
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
{streaming}
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
{thinking && (
|
|
348
|
+
<div className="self-start text-[11px] px-3.5" style={{ color: "#a1a1aa" }}>
|
|
349
|
+
<span style={{ animation: "rt-pulse 1.2s ease-in-out infinite" }}>· · ·</span>
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</AutoScroll>
|
|
353
|
+
|
|
354
|
+
{transcript.speaking && (
|
|
355
|
+
<div
|
|
356
|
+
className="flex items-center px-4 py-2 text-xs italic min-h-8"
|
|
357
|
+
style={{ background: "#fafafa", borderTop: "1px solid #e4e4e7", color: "#71717a" }}
|
|
358
|
+
>
|
|
359
|
+
<span
|
|
360
|
+
className="w-2 h-2 rounded-full inline-block mr-2"
|
|
361
|
+
style={{ background: "#16a34a", animation: "rt-pulse 1.5s ease-in-out infinite" }}
|
|
362
|
+
/>
|
|
363
|
+
{transcript.text}
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
</>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The live status dot, on its own subscription rather than a field off a
|
|
371
|
+
* whole-page read — the header is the only thing here that wants it. */
|
|
372
|
+
function StatusReadout() {
|
|
373
|
+
const state = useSessionSelector((s) => s.state);
|
|
374
|
+
return (
|
|
375
|
+
<>
|
|
376
|
+
<span
|
|
377
|
+
className="w-2 h-2 rounded-full inline-block"
|
|
378
|
+
style={{
|
|
379
|
+
background: STATE_COLORS[state],
|
|
380
|
+
animation:
|
|
381
|
+
state === "listening"
|
|
382
|
+
? "rt-pulse 1.5s ease-in-out infinite"
|
|
383
|
+
: state === "thinking"
|
|
384
|
+
? "rt-pulse 0.8s ease-in-out infinite"
|
|
385
|
+
: "none",
|
|
386
|
+
}}
|
|
387
|
+
title={state}
|
|
388
|
+
/>
|
|
389
|
+
<span className="text-[11px] font-normal" style={{ color: "#a1a1aa" }}>
|
|
390
|
+
{state}
|
|
391
|
+
</span>
|
|
392
|
+
</>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The fatal-error banner.
|
|
398
|
+
*
|
|
399
|
+
* `role="alert"` is the part a hand-rolled banner leaves out and the part that
|
|
400
|
+
* matters: per the `fatalError` latch in the session core this is the only
|
|
401
|
+
* remaining signal — the status readout beside it goes back to reading like a
|
|
402
|
+
* live call — and a screen reader is never told an unannounced one appeared.
|
|
403
|
+
* `ConsoleShell` carries it, and this template's two-pane page cannot use that
|
|
404
|
+
* shell as its frame; the attribute is what it is here for.
|
|
405
|
+
*/
|
|
406
|
+
function ErrorBanner() {
|
|
407
|
+
const error = useSessionSelector((s) => s.error);
|
|
408
|
+
if (!error) return null;
|
|
409
|
+
return (
|
|
410
|
+
<div
|
|
411
|
+
role="alert"
|
|
412
|
+
className="px-4 py-2 text-xs"
|
|
413
|
+
style={{ background: "#fef2f2", color: "#b91c1c", borderTop: "1px solid #fecaca" }}
|
|
414
|
+
>
|
|
415
|
+
{error.message} ({error.code})
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The call controls: the one place a whole-session read is what is wanted —
|
|
421
|
+
* `started`, `running` and three methods, in three buttons. */
|
|
422
|
+
function CallControls({ productCount }: { productCount: number }) {
|
|
221
423
|
const session = useSession();
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
424
|
+
return (
|
|
425
|
+
<div
|
|
426
|
+
className="flex items-center gap-2 px-4 py-3"
|
|
427
|
+
style={{ background: "#ffffff", borderTop: "1px solid #e4e4e7" }}
|
|
428
|
+
>
|
|
429
|
+
{!session.started ? (
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
|
|
433
|
+
style={{ background: "#2563eb" }}
|
|
434
|
+
onClick={() => session.start()}
|
|
435
|
+
>
|
|
436
|
+
Start call
|
|
437
|
+
</button>
|
|
438
|
+
) : (
|
|
439
|
+
<>
|
|
440
|
+
<button
|
|
441
|
+
type="button"
|
|
442
|
+
className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer"
|
|
443
|
+
style={{
|
|
444
|
+
background: session.running ? "#e4e4e7" : "#2563eb",
|
|
445
|
+
color: session.running ? "#18181b" : "#ffffff",
|
|
446
|
+
}}
|
|
447
|
+
onClick={() => session.toggle()}
|
|
448
|
+
>
|
|
449
|
+
{session.running ? "Hold" : "Resume"}
|
|
450
|
+
</button>
|
|
451
|
+
{/* end() hangs up and flips `started` back, so the UI
|
|
452
|
+
returns to "Start call" and the next start is a brand-new
|
|
453
|
+
session (fresh store, greeting included). reset() would
|
|
454
|
+
keep the call live — the buttons never toggle back. */}
|
|
455
|
+
<button
|
|
456
|
+
type="button"
|
|
457
|
+
className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
|
|
458
|
+
style={{ background: "#dc2626" }}
|
|
459
|
+
onClick={() => session.end()}
|
|
460
|
+
>
|
|
461
|
+
End
|
|
462
|
+
</button>
|
|
463
|
+
</>
|
|
464
|
+
)}
|
|
465
|
+
<div className="flex-1" />
|
|
466
|
+
<span className="text-[10px] tabular-nums" style={{ color: "#a1a1aa" }}>
|
|
467
|
+
{productCount} products
|
|
468
|
+
</span>
|
|
469
|
+
</div>
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function App() {
|
|
474
|
+
// The agent's own store, projected by `syncState` after every tool call —
|
|
475
|
+
// the only subscription at this level now. The session reads that used to sit
|
|
476
|
+
// beside it moved into the four components above, so a partial transcript no
|
|
477
|
+
// longer re-renders the customer file.
|
|
227
478
|
const view = useAgentState<StoreView>(EMPTY_VIEW);
|
|
228
479
|
|
|
229
480
|
const lastAction = view.activity.at(-1);
|
|
@@ -242,22 +493,7 @@ function App() {
|
|
|
242
493
|
<div className="flex items-center gap-2.5 text-base font-semibold">
|
|
243
494
|
<span style={{ color: "#2563eb" }}>◆</span>
|
|
244
495
|
Retail Support
|
|
245
|
-
<
|
|
246
|
-
className="w-2 h-2 rounded-full inline-block"
|
|
247
|
-
style={{
|
|
248
|
-
background: STATE_COLORS[session.state],
|
|
249
|
-
animation:
|
|
250
|
-
session.state === "listening"
|
|
251
|
-
? "rt-pulse 1.5s ease-in-out infinite"
|
|
252
|
-
: session.state === "thinking"
|
|
253
|
-
? "rt-pulse 0.8s ease-in-out infinite"
|
|
254
|
-
: "none",
|
|
255
|
-
}}
|
|
256
|
-
title={session.state}
|
|
257
|
-
/>
|
|
258
|
-
<span className="text-[11px] font-normal" style={{ color: "#a1a1aa" }}>
|
|
259
|
-
{session.state}
|
|
260
|
-
</span>
|
|
496
|
+
<StatusReadout />
|
|
261
497
|
</div>
|
|
262
498
|
<span
|
|
263
499
|
className="px-2.5 py-1 rounded text-[10px] font-semibold uppercase tracking-wider"
|
|
@@ -280,99 +516,9 @@ function App() {
|
|
|
280
516
|
className="flex flex-col overflow-hidden"
|
|
281
517
|
style={{ borderRight: "1px solid #e4e4e7" }}
|
|
282
518
|
>
|
|
283
|
-
<
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
>
|
|
287
|
-
{session.messages.length === 0 && (
|
|
288
|
-
<div className="text-center p-10 text-[13px]" style={{ color: "#a1a1aa" }}>
|
|
289
|
-
Press start, then read one of the emails from “Who to be” to the agent.
|
|
290
|
-
</div>
|
|
291
|
-
)}
|
|
292
|
-
{session.messages.map((message: ChatMessage, index: number) => (
|
|
293
|
-
<div
|
|
294
|
-
key={index}
|
|
295
|
-
className="rounded-lg text-[13px] max-w-[80%] px-3.5 py-2.5"
|
|
296
|
-
style={{
|
|
297
|
-
lineHeight: 1.55,
|
|
298
|
-
alignSelf: message.role === "assistant" ? "flex-start" : "flex-end",
|
|
299
|
-
background: message.role === "assistant" ? "#ffffff" : "#2563eb",
|
|
300
|
-
color: message.role === "assistant" ? "#18181b" : "#ffffff",
|
|
301
|
-
border: message.role === "assistant" ? "1px solid #e4e4e7" : "none",
|
|
302
|
-
animation: "rt-slide-in 0.2s ease-out",
|
|
303
|
-
}}
|
|
304
|
-
>
|
|
305
|
-
{message.content}
|
|
306
|
-
</div>
|
|
307
|
-
))}
|
|
308
|
-
</AutoScroll>
|
|
309
|
-
|
|
310
|
-
{speaking && (
|
|
311
|
-
<div
|
|
312
|
-
className="flex items-center px-4 py-2 text-xs italic min-h-8"
|
|
313
|
-
style={{ background: "#fafafa", borderTop: "1px solid #e4e4e7", color: "#71717a" }}
|
|
314
|
-
>
|
|
315
|
-
<span
|
|
316
|
-
className="w-2 h-2 rounded-full inline-block mr-2"
|
|
317
|
-
style={{ background: "#16a34a", animation: "rt-pulse 1.5s ease-in-out infinite" }}
|
|
318
|
-
/>
|
|
319
|
-
{userTranscript}
|
|
320
|
-
</div>
|
|
321
|
-
)}
|
|
322
|
-
{session.error && (
|
|
323
|
-
<div
|
|
324
|
-
className="px-4 py-2 text-xs"
|
|
325
|
-
style={{ background: "#fef2f2", color: "#b91c1c", borderTop: "1px solid #fecaca" }}
|
|
326
|
-
>
|
|
327
|
-
{session.error.message} ({session.error.code})
|
|
328
|
-
</div>
|
|
329
|
-
)}
|
|
330
|
-
|
|
331
|
-
<div
|
|
332
|
-
className="flex items-center gap-2 px-4 py-3"
|
|
333
|
-
style={{ background: "#ffffff", borderTop: "1px solid #e4e4e7" }}
|
|
334
|
-
>
|
|
335
|
-
{!session.started ? (
|
|
336
|
-
<button
|
|
337
|
-
type="button"
|
|
338
|
-
className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
|
|
339
|
-
style={{ background: "#2563eb" }}
|
|
340
|
-
onClick={() => session.start()}
|
|
341
|
-
>
|
|
342
|
-
Start call
|
|
343
|
-
</button>
|
|
344
|
-
) : (
|
|
345
|
-
<>
|
|
346
|
-
<button
|
|
347
|
-
type="button"
|
|
348
|
-
className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer"
|
|
349
|
-
style={{
|
|
350
|
-
background: session.running ? "#e4e4e7" : "#2563eb",
|
|
351
|
-
color: session.running ? "#18181b" : "#ffffff",
|
|
352
|
-
}}
|
|
353
|
-
onClick={() => session.toggle()}
|
|
354
|
-
>
|
|
355
|
-
{session.running ? "Hold" : "Resume"}
|
|
356
|
-
</button>
|
|
357
|
-
{/* end() hangs up and flips `started` back, so the UI
|
|
358
|
-
returns to "Start call" and the next start is a brand-new
|
|
359
|
-
session (fresh store, greeting included). reset() would
|
|
360
|
-
keep the call live — the buttons never toggle back. */}
|
|
361
|
-
<button
|
|
362
|
-
type="button"
|
|
363
|
-
className="px-4 py-2 border-none rounded-md text-xs font-semibold cursor-pointer text-white"
|
|
364
|
-
style={{ background: "#dc2626" }}
|
|
365
|
-
onClick={() => session.end()}
|
|
366
|
-
>
|
|
367
|
-
End
|
|
368
|
-
</button>
|
|
369
|
-
</>
|
|
370
|
-
)}
|
|
371
|
-
<div className="flex-1" />
|
|
372
|
-
<span className="text-[10px] tabular-nums" style={{ color: "#a1a1aa" }}>
|
|
373
|
-
{view.productCount} products
|
|
374
|
-
</span>
|
|
375
|
-
</div>
|
|
519
|
+
<Conversation />
|
|
520
|
+
<ErrorBanner />
|
|
521
|
+
<CallControls productCount={view.productCount} />
|
|
376
522
|
</div>
|
|
377
523
|
|
|
378
524
|
{/* Sidebar: the customer file */}
|
|
@@ -380,6 +526,8 @@ function App() {
|
|
|
380
526
|
className="rt-scroll overflow-y-auto p-3 flex flex-col gap-3"
|
|
381
527
|
style={{ background: "#f4f4f5" }}
|
|
382
528
|
>
|
|
529
|
+
{view.pending && <PendingChange pending={view.pending} />}
|
|
530
|
+
|
|
383
531
|
{!view.customer && (
|
|
384
532
|
<Panel title="Who to be">
|
|
385
533
|
<PersonaList />
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changing which payment method a pending order is charged to, as a plan and an
|
|
3
|
+
* apply (see `cancel.ts` for why every mutating action is split that way).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
|
|
7
|
+
import { resolveOrder } from "./resolve.ts";
|
|
8
|
+
import type { RetailState } from "./shared.ts";
|
|
9
|
+
import { authenticatedUser, findPaymentMethod, isGiftCard, money } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
export interface PaymentPlan {
|
|
12
|
+
readBack: string;
|
|
13
|
+
orderId: string;
|
|
14
|
+
/** Where the order is charged after the change. */
|
|
15
|
+
newMethodId: string;
|
|
16
|
+
/** Where the original charge goes back to. */
|
|
17
|
+
oldMethodId: string;
|
|
18
|
+
amount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function planPayment(
|
|
22
|
+
state: RetailState,
|
|
23
|
+
spokenOrderId: string,
|
|
24
|
+
newMethodId: string,
|
|
25
|
+
): PaymentPlan | ToolFailure {
|
|
26
|
+
const user = authenticatedUser(state);
|
|
27
|
+
if (isToolFailure(user)) return user;
|
|
28
|
+
|
|
29
|
+
const order = resolveOrder(state, spokenOrderId);
|
|
30
|
+
if (isToolFailure(order)) return order;
|
|
31
|
+
|
|
32
|
+
if (!order.status.startsWith("pending")) {
|
|
33
|
+
return {
|
|
34
|
+
error: `Order ${order.order_id} is ${order.status}, and only a pending order's payment method can be changed.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const newMethod = findPaymentMethod(user, newMethodId);
|
|
39
|
+
if (isToolFailure(newMethod)) return newMethod;
|
|
40
|
+
|
|
41
|
+
const original = order.payment_history[0];
|
|
42
|
+
if (order.payment_history.length !== 1 || original?.transaction_type !== "payment") {
|
|
43
|
+
return {
|
|
44
|
+
error: `Order ${order.order_id} does not have exactly one payment on record, so its payment method cannot be changed.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (original.payment_method_id === newMethodId) {
|
|
48
|
+
return {
|
|
49
|
+
error: `Order ${order.order_id} is already paid with ${newMethodId}. The new method must be different.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const amount = original.amount;
|
|
54
|
+
if (isGiftCard(newMethod) && newMethod.balance < amount) {
|
|
55
|
+
return {
|
|
56
|
+
error: `Gift card ${newMethodId}'s balance ($${newMethod.balance.toFixed(2)}) does not cover the $${amount.toFixed(2)} order total.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
readBack:
|
|
62
|
+
`charge order ${order.order_id} — $${amount.toFixed(2)} — to ${newMethodId} instead, ` +
|
|
63
|
+
`refunding ${original.payment_method_id}`,
|
|
64
|
+
orderId: order.order_id,
|
|
65
|
+
newMethodId,
|
|
66
|
+
oldMethodId: original.payment_method_id,
|
|
67
|
+
amount,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function applyPayment(state: RetailState, plan: PaymentPlan) {
|
|
72
|
+
const order = state.store.orders[plan.orderId];
|
|
73
|
+
const user = order ? state.store.users[order.user_id] : undefined;
|
|
74
|
+
|
|
75
|
+
if (order && user) {
|
|
76
|
+
order.payment_history.push(
|
|
77
|
+
{
|
|
78
|
+
transaction_type: "payment",
|
|
79
|
+
amount: plan.amount,
|
|
80
|
+
payment_method_id: plan.newMethodId,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
transaction_type: "refund",
|
|
84
|
+
amount: plan.amount,
|
|
85
|
+
payment_method_id: plan.oldMethodId,
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const newMethod = user.payment_methods[plan.newMethodId];
|
|
90
|
+
if (newMethod && isGiftCard(newMethod)) {
|
|
91
|
+
newMethod.balance = money(newMethod.balance - plan.amount);
|
|
92
|
+
}
|
|
93
|
+
const oldMethod = user.payment_methods[plan.oldMethodId];
|
|
94
|
+
if (oldMethod && isGiftCard(oldMethod)) {
|
|
95
|
+
oldMethod.balance = money(oldMethod.balance + plan.amount);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
order_id: plan.orderId,
|
|
101
|
+
status: order?.status ?? "pending",
|
|
102
|
+
amount: plan.amount,
|
|
103
|
+
paid_with: plan.newMethodId,
|
|
104
|
+
refunded_to: plan.oldMethodId,
|
|
105
|
+
message: `Order ${plan.orderId} is now charged to ${plan.newMethodId}, and $${plan.amount.toFixed(2)} is going back to ${plan.oldMethodId}.`,
|
|
106
|
+
};
|
|
107
|
+
}
|