@invago/mixin 1.0.9 → 1.0.10

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.
@@ -0,0 +1,211 @@
1
+ import crypto from "node:crypto";
2
+ import axios from "axios";
3
+ import type { MixinMixpayConfig } from "./config-schema.js";
4
+ import type { MixpayOrderStatus } from "./mixpay-store.js";
5
+
6
+ const DEFAULT_API_BASE_URL = "https://api.mixpay.me/v1";
7
+
8
+ export type CreateMixpayPaymentInput = {
9
+ config: MixinMixpayConfig;
10
+ orderId?: string;
11
+ quoteAmount: string;
12
+ quoteAssetId: string;
13
+ settlementAssetId?: string;
14
+ memo?: string;
15
+ expireMinutes?: number;
16
+ };
17
+
18
+ export type MixpayCreateResult = {
19
+ orderId: string;
20
+ traceId: string;
21
+ paymentId?: string;
22
+ code?: string;
23
+ paymentUrl?: string;
24
+ createdAt: string;
25
+ expireAt?: string;
26
+ raw: Record<string, unknown>;
27
+ };
28
+
29
+ export type MixpayPaymentResult = {
30
+ orderId: string;
31
+ traceId?: string;
32
+ payeeId?: string;
33
+ quoteAssetId?: string;
34
+ quoteAmount?: string;
35
+ settlementAssetId?: string;
36
+ status: MixpayOrderStatus;
37
+ rawStatus: string;
38
+ settleStatus?: string;
39
+ raw: Record<string, unknown>;
40
+ };
41
+
42
+ function resolveApiBaseUrl(config: MixinMixpayConfig): string {
43
+ return (config.apiBaseUrl?.trim() || DEFAULT_API_BASE_URL).replace(/\/+$/, "");
44
+ }
45
+
46
+ function generateShortId(): string {
47
+ return crypto.randomBytes(5).toString("hex");
48
+ }
49
+
50
+ export function createMixpayOrderId(): string {
51
+ return `mixpay_${Date.now()}_${generateShortId()}`;
52
+ }
53
+
54
+ export function createMixpayTraceId(): string {
55
+ return crypto.randomUUID();
56
+ }
57
+
58
+ function normalizeAmount(value: string): string {
59
+ const normalized = value.trim();
60
+ if (!normalized || !/^\d+(\.\d+)?$/.test(normalized)) {
61
+ throw new Error("invalid MixPay quote amount");
62
+ }
63
+ return normalized;
64
+ }
65
+
66
+ function mapMixpayStatus(rawStatus: string, settleStatus?: string, quoteAmount?: string, paidAmount?: string): MixpayOrderStatus {
67
+ const status = rawStatus.trim().toLowerCase();
68
+ if (status === "success" && settleStatus?.trim().toLowerCase() === "success") {
69
+ return "success";
70
+ }
71
+ if (status === "success") {
72
+ return "pending";
73
+ }
74
+ if (status === "pending") {
75
+ return "pending";
76
+ }
77
+ if (status === "unpaid") {
78
+ return "unpaid";
79
+ }
80
+ if (status === "failed" || status === "fail") {
81
+ return "failed";
82
+ }
83
+ if (status === "expired") {
84
+ return "expired";
85
+ }
86
+ if (quoteAmount && paidAmount) {
87
+ const expected = Number.parseFloat(quoteAmount);
88
+ const actual = Number.parseFloat(paidAmount);
89
+ if (Number.isFinite(expected) && Number.isFinite(actual) && actual < expected) {
90
+ return "paid_less";
91
+ }
92
+ }
93
+ return "unknown";
94
+ }
95
+
96
+ function extractString(obj: Record<string, unknown>, ...keys: string[]): string | undefined {
97
+ for (const key of keys) {
98
+ const value = obj[key];
99
+ if (typeof value === "string" && value.trim()) {
100
+ return value.trim();
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ function extractDataRecord(payload: unknown): Record<string, unknown> {
107
+ if (payload && typeof payload === "object") {
108
+ const record = payload as Record<string, unknown>;
109
+ const nested = record.data;
110
+ if (nested && typeof nested === "object") {
111
+ return nested as Record<string, unknown>;
112
+ }
113
+ return record;
114
+ }
115
+ return {};
116
+ }
117
+
118
+ export async function createMixpayPayment(input: CreateMixpayPaymentInput): Promise<MixpayCreateResult> {
119
+ if (!input.config.enabled) {
120
+ throw new Error("MixPay is disabled");
121
+ }
122
+ if (!input.config.payeeId?.trim()) {
123
+ throw new Error("MixPay payeeId is not configured");
124
+ }
125
+
126
+ const orderId = input.orderId?.trim() || createMixpayOrderId();
127
+ const traceId = createMixpayTraceId();
128
+ const quoteAmount = normalizeAmount(input.quoteAmount);
129
+ const quoteAssetId = input.quoteAssetId.trim();
130
+ if (!quoteAssetId) {
131
+ throw new Error("MixPay quoteAssetId is not configured");
132
+ }
133
+ const settlementAssetId = input.settlementAssetId?.trim() || input.config.defaultSettlementAssetId?.trim();
134
+ const expireMinutes = Math.max(1, Math.floor(input.expireMinutes ?? input.config.expireMinutes ?? 15));
135
+ const expiredTimestamp = Date.now() + expireMinutes * 60 * 1000;
136
+
137
+ const body = new URLSearchParams();
138
+ body.set("payeeId", input.config.payeeId.trim());
139
+ body.set("orderId", orderId);
140
+ body.set("traceId", traceId);
141
+ body.set("quoteAssetId", quoteAssetId);
142
+ body.set("quoteAmount", quoteAmount);
143
+ body.set("expiredTimestamp", String(expiredTimestamp));
144
+ if (settlementAssetId) {
145
+ body.set("settlementAssetId", settlementAssetId);
146
+ }
147
+ if (input.memo?.trim()) {
148
+ body.set("memo", input.memo.trim());
149
+ }
150
+
151
+ const response = await axios.post(`${resolveApiBaseUrl(input.config)}/one_time_payment`, body, {
152
+ headers: {
153
+ "Content-Type": "application/x-www-form-urlencoded",
154
+ },
155
+ timeout: 20_000,
156
+ });
157
+
158
+ const data = extractDataRecord(response.data);
159
+ const code = extractString(data, "code");
160
+ const paymentId = extractString(data, "paymentId", "payment_id", "id");
161
+ const paymentUrl = code ? `https://mixpay.me/code/${code}` : extractString(data, "paymentUrl", "payment_url", "url");
162
+ const createdAt = new Date().toISOString();
163
+
164
+ return {
165
+ orderId,
166
+ traceId,
167
+ paymentId,
168
+ code,
169
+ paymentUrl,
170
+ createdAt,
171
+ expireAt: new Date(expiredTimestamp).toISOString(),
172
+ raw: data,
173
+ };
174
+ }
175
+
176
+ export async function getMixpayPaymentResult(params: {
177
+ config: MixinMixpayConfig;
178
+ orderId: string;
179
+ traceId?: string;
180
+ }): Promise<MixpayPaymentResult> {
181
+ if (!params.config.enabled) {
182
+ throw new Error("MixPay is disabled");
183
+ }
184
+
185
+ const response = await axios.get(`${resolveApiBaseUrl(params.config)}/payments_result`, {
186
+ params: {
187
+ orderId: params.orderId,
188
+ traceId: params.traceId,
189
+ },
190
+ timeout: 20_000,
191
+ });
192
+
193
+ const data = extractDataRecord(response.data);
194
+ const rawStatus = extractString(data, "status") ?? "unknown";
195
+ const settleStatus = extractString(data, "settleStatus", "settle_status");
196
+ const quoteAmount = extractString(data, "quoteAmount", "quote_amount");
197
+ const paidAmount = extractString(data, "baseAmount", "base_amount", "paidAmount", "paid_amount");
198
+
199
+ return {
200
+ orderId: extractString(data, "orderId", "order_id") ?? params.orderId,
201
+ traceId: extractString(data, "traceId", "trace_id") ?? params.traceId,
202
+ payeeId: extractString(data, "payeeId", "payee_id"),
203
+ quoteAssetId: extractString(data, "quoteAssetId", "quote_asset_id"),
204
+ quoteAmount,
205
+ settlementAssetId: extractString(data, "settlementAssetId", "settlement_asset_id"),
206
+ status: mapMixpayStatus(rawStatus, settleStatus, quoteAmount, paidAmount),
207
+ rawStatus,
208
+ settleStatus,
209
+ raw: data,
210
+ };
211
+ }
@@ -0,0 +1,205 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { getMixinRuntime } from "./runtime.js";
5
+
6
+ export type MixpayOrderStatus =
7
+ | "unpaid"
8
+ | "pending"
9
+ | "paid_less"
10
+ | "success"
11
+ | "failed"
12
+ | "expired"
13
+ | "unknown";
14
+
15
+ export type MixpayOrderRecord = {
16
+ orderId: string;
17
+ traceId: string;
18
+ paymentId?: string;
19
+ code?: string;
20
+ paymentUrl?: string;
21
+ accountId: string;
22
+ conversationId: string;
23
+ recipientId?: string;
24
+ creatorId: string;
25
+ quoteAssetId: string;
26
+ quoteAmount: string;
27
+ settlementAssetId?: string;
28
+ memo?: string;
29
+ status: MixpayOrderStatus;
30
+ rawStatus?: string;
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ expireAt?: string;
34
+ lastPolledAt?: string;
35
+ lastNotifyStatus?: string;
36
+ latestError?: string;
37
+ };
38
+
39
+ type MixpayStoreState = {
40
+ loaded: boolean;
41
+ persistChain: Promise<void>;
42
+ orders: MixpayOrderRecord[];
43
+ };
44
+
45
+ const state: MixpayStoreState = {
46
+ loaded: false,
47
+ persistChain: Promise.resolve(),
48
+ orders: [],
49
+ };
50
+
51
+ function resolveFallbackStoreDir(env: NodeJS.ProcessEnv = process.env): string {
52
+ const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
53
+ if (stateOverride) {
54
+ return path.join(stateOverride, "mixin");
55
+ }
56
+ const openClawHome = env.OPENCLAW_HOME?.trim();
57
+ if (openClawHome) {
58
+ return path.join(openClawHome, ".openclaw", "mixin");
59
+ }
60
+ return path.join(os.homedir(), ".openclaw", "mixin");
61
+ }
62
+
63
+ function resolveStoreDir(): string {
64
+ try {
65
+ return path.join(getMixinRuntime().state.resolveStateDir(process.env, os.homedir), "mixin");
66
+ } catch {
67
+ return resolveFallbackStoreDir();
68
+ }
69
+ }
70
+
71
+ function resolveStorePaths(): {
72
+ storeDir: string;
73
+ storeFile: string;
74
+ storeTmpFile: string;
75
+ } {
76
+ const storeDir = resolveStoreDir();
77
+ const storeFile = path.join(storeDir, "mixpay-orders.json");
78
+ return {
79
+ storeDir,
80
+ storeFile,
81
+ storeTmpFile: `${storeFile}.tmp`,
82
+ };
83
+ }
84
+
85
+ function normalizeRecord(record: MixpayOrderRecord): MixpayOrderRecord {
86
+ return {
87
+ ...record,
88
+ recipientId: record.recipientId || undefined,
89
+ paymentId: record.paymentId || undefined,
90
+ code: record.code || undefined,
91
+ paymentUrl: record.paymentUrl || undefined,
92
+ settlementAssetId: record.settlementAssetId || undefined,
93
+ memo: record.memo || undefined,
94
+ rawStatus: record.rawStatus || undefined,
95
+ expireAt: record.expireAt || undefined,
96
+ lastPolledAt: record.lastPolledAt || undefined,
97
+ lastNotifyStatus: record.lastNotifyStatus || undefined,
98
+ latestError: record.latestError || undefined,
99
+ };
100
+ }
101
+
102
+ const ORDER_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
103
+ const TERMINAL_STATUSES: MixpayOrderStatus[] = ["success", "failed", "expired"];
104
+
105
+ async function ensureLoaded(): Promise<void> {
106
+ if (state.loaded) {
107
+ return;
108
+ }
109
+ const { storeFile } = resolveStorePaths();
110
+ try {
111
+ const raw = await readFile(storeFile, "utf8");
112
+ const parsed = JSON.parse(raw) as { orders?: MixpayOrderRecord[] } | MixpayOrderRecord[];
113
+ const orders = Array.isArray(parsed) ? parsed : Array.isArray(parsed.orders) ? parsed.orders : [];
114
+ const cutoff = Date.now() - ORDER_RETENTION_MS;
115
+ state.orders = orders
116
+ .map(normalizeRecord)
117
+ .filter((order) => {
118
+ if (!TERMINAL_STATUSES.includes(order.status)) {
119
+ return true;
120
+ }
121
+ return Date.parse(order.updatedAt) > cutoff;
122
+ });
123
+ } catch {
124
+ state.orders = [];
125
+ }
126
+ state.loaded = true;
127
+ }
128
+
129
+ async function persist(): Promise<void> {
130
+ const { storeDir, storeFile, storeTmpFile } = resolveStorePaths();
131
+ await mkdir(storeDir, { recursive: true });
132
+ const raw = JSON.stringify({ orders: state.orders }, null, 2);
133
+ await writeFile(storeTmpFile, raw, "utf8");
134
+ await rename(storeTmpFile, storeFile);
135
+ }
136
+
137
+ function queuePersist(): Promise<void> {
138
+ state.persistChain = state.persistChain.then(() => persist());
139
+ return state.persistChain;
140
+ }
141
+
142
+ export async function getMixpayStoreSnapshot(): Promise<{
143
+ storeDir: string;
144
+ storeFile: string;
145
+ total: number;
146
+ pending: number;
147
+ }> {
148
+ await ensureLoaded();
149
+ const { storeDir, storeFile } = resolveStorePaths();
150
+ const pending = state.orders.filter((order) => order.status === "unpaid" || order.status === "pending").length;
151
+ return {
152
+ storeDir,
153
+ storeFile,
154
+ total: state.orders.length,
155
+ pending,
156
+ };
157
+ }
158
+
159
+ export async function createMixpayOrder(record: MixpayOrderRecord): Promise<MixpayOrderRecord> {
160
+ await ensureLoaded();
161
+ state.orders = [normalizeRecord(record), ...state.orders.filter((item) => item.orderId !== record.orderId)];
162
+ await queuePersist();
163
+ return record;
164
+ }
165
+
166
+ export async function updateMixpayOrder(
167
+ orderId: string,
168
+ updater: (current: MixpayOrderRecord) => MixpayOrderRecord,
169
+ ): Promise<MixpayOrderRecord | null> {
170
+ await ensureLoaded();
171
+ const index = state.orders.findIndex((item) => item.orderId === orderId);
172
+ if (index < 0) {
173
+ return null;
174
+ }
175
+ const next = normalizeRecord(updater(state.orders[index]!));
176
+ state.orders[index] = next;
177
+ await queuePersist();
178
+ return next;
179
+ }
180
+
181
+ export async function findMixpayOrder(orderId: string): Promise<MixpayOrderRecord | null> {
182
+ await ensureLoaded();
183
+ return state.orders.find((item) => item.orderId === orderId) ?? null;
184
+ }
185
+
186
+ export async function listRecentMixpayOrders(params?: {
187
+ accountId?: string;
188
+ conversationId?: string;
189
+ limit?: number;
190
+ }): Promise<MixpayOrderRecord[]> {
191
+ await ensureLoaded();
192
+ const limit = Math.max(1, params?.limit ?? 5);
193
+ return state.orders
194
+ .filter((item) => !params?.accountId || item.accountId === params.accountId)
195
+ .filter((item) => !params?.conversationId || item.conversationId === params.conversationId)
196
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
197
+ .slice(0, limit);
198
+ }
199
+
200
+ export async function listPendingMixpayOrders(): Promise<MixpayOrderRecord[]> {
201
+ await ensureLoaded();
202
+ return state.orders
203
+ .filter((item) => item.status === "unpaid" || item.status === "pending")
204
+ .sort((a, b) => Date.parse(a.updatedAt) - Date.parse(b.updatedAt));
205
+ }