includio-cms 0.15.0 → 0.15.2
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/CHANGELOG.md +61 -0
- package/DOCS.md +231 -1
- package/ROADMAP.md +7 -2
- package/dist/admin/client/shop/shipping-method-edit-page.svelte +1 -0
- package/dist/admin/client/shop/shipping-method-form.svelte +89 -21
- package/dist/admin/client/shop/shipping-method-form.svelte.d.ts +8 -1
- package/dist/admin/client/shop/shipping-method-new-page.svelte +1 -0
- package/dist/admin/client/shop/shipping-methods-list-page.svelte +7 -4
- package/dist/admin/client/shop/shop-products-list-page.svelte +2 -2
- package/dist/admin/components/fields/shop-field.svelte +63 -22
- package/dist/admin/remote/shop.remote.d.ts +16 -56
- package/dist/admin/remote/shop.remote.js +6 -4
- package/dist/cli/scaffold/admin.js +32 -0
- package/dist/db-postgres/schema/shop/order.d.ts +34 -0
- package/dist/db-postgres/schema/shop/order.js +2 -0
- package/dist/db-postgres/schema/shop/product.d.ts +4 -4
- package/dist/db-postgres/schema/shop/product.js +3 -2
- package/dist/db-postgres/schema/shop/productVariant.d.ts +4 -4
- package/dist/db-postgres/schema/shop/productVariant.js +3 -2
- package/dist/db-postgres/schema/shop/shippingMethod.d.ts +23 -4
- package/dist/db-postgres/schema/shop/shippingMethod.js +4 -2
- package/dist/shop/adapters/payu/client.d.ts +22 -0
- package/dist/shop/adapters/payu/client.js +78 -0
- package/dist/shop/adapters/payu/index.d.ts +24 -0
- package/dist/shop/adapters/payu/index.js +88 -0
- package/dist/shop/adapters/payu/payload.d.ts +48 -0
- package/dist/shop/adapters/payu/payload.js +48 -0
- package/dist/shop/adapters/payu/signature.d.ts +12 -0
- package/dist/shop/adapters/payu/signature.js +50 -0
- package/dist/shop/adapters/payu/status-map.d.ts +3 -0
- package/dist/shop/adapters/payu/status-map.js +14 -0
- package/dist/shop/cart/order-token-cookie.d.ts +9 -0
- package/dist/shop/cart/order-token-cookie.js +40 -0
- package/dist/shop/client/index.d.ts +64 -1
- package/dist/shop/client/index.js +9 -0
- package/dist/shop/client/use-order.svelte.d.ts +32 -0
- package/dist/shop/client/use-order.svelte.js +105 -0
- package/dist/shop/http/checkout-handler.js +47 -4
- package/dist/shop/http/index.d.ts +4 -0
- package/dist/shop/http/index.js +4 -0
- package/dist/shop/http/order-handler.d.ts +4 -0
- package/dist/shop/http/order-handler.js +85 -0
- package/dist/shop/http/refresh-payment-handler.d.ts +4 -0
- package/dist/shop/http/refresh-payment-handler.js +73 -0
- package/dist/shop/http/retry-payment-handler.d.ts +4 -0
- package/dist/shop/http/retry-payment-handler.js +99 -0
- package/dist/shop/http/retry-payment-logic.d.ts +2 -0
- package/dist/shop/http/retry-payment-logic.js +4 -0
- package/dist/shop/http/shipping-handler.js +2 -1
- package/dist/shop/http/webhook-handler.d.ts +4 -0
- package/dist/shop/http/webhook-handler.js +73 -0
- package/dist/shop/http/webhook-logic.d.ts +4 -0
- package/dist/shop/http/webhook-logic.js +21 -0
- package/dist/shop/index.d.ts +3 -1
- package/dist/shop/index.js +3 -1
- package/dist/shop/pricing.d.ts +4 -0
- package/dist/shop/pricing.js +18 -0
- package/dist/shop/server/cart-hydrate.js +6 -3
- package/dist/shop/server/email.js +18 -2
- package/dist/shop/server/order-access-url.d.ts +7 -0
- package/dist/shop/server/order-access-url.js +6 -0
- package/dist/shop/server/orders.d.ts +1 -0
- package/dist/shop/server/orders.js +12 -0
- package/dist/shop/server/payment-compat.d.ts +5 -0
- package/dist/shop/server/payment-compat.js +9 -0
- package/dist/shop/server/populate.d.ts +2 -0
- package/dist/shop/server/shipping.d.ts +12 -4
- package/dist/shop/server/shipping.js +24 -14
- package/dist/shop/server/shop-data.d.ts +8 -2
- package/dist/shop/server/shop-data.js +18 -10
- package/dist/shop/svelte/OrderStatus.svelte +368 -0
- package/dist/shop/svelte/OrderStatus.svelte.d.ts +14 -0
- package/dist/shop/svelte/index.d.ts +3 -0
- package/dist/shop/svelte/index.js +2 -0
- package/dist/shop/svelte/labels.d.ts +25 -0
- package/dist/shop/svelte/labels.js +41 -0
- package/dist/shop/types.d.ts +19 -1
- package/dist/updates/0.15.1/index.d.ts +2 -0
- package/dist/updates/0.15.1/index.js +27 -0
- package/dist/updates/0.15.2/index.d.ts +2 -0
- package/dist/updates/0.15.2/index.js +18 -0
- package/dist/updates/index.js +3 -1
- package/package.json +5 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function payuBaseUrl(env) {
|
|
2
|
+
return env === 'production' ? 'https://secure.payu.com' : 'https://secure.snd.payu.com';
|
|
3
|
+
}
|
|
4
|
+
export class PayuClient {
|
|
5
|
+
opts;
|
|
6
|
+
cachedToken = null;
|
|
7
|
+
fetchImpl;
|
|
8
|
+
constructor(opts) {
|
|
9
|
+
this.opts = opts;
|
|
10
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
11
|
+
}
|
|
12
|
+
get base() {
|
|
13
|
+
return payuBaseUrl(this.opts.environment);
|
|
14
|
+
}
|
|
15
|
+
async getAccessToken() {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
if (this.cachedToken && this.cachedToken.expiresAt > now + 30_000) {
|
|
18
|
+
return this.cachedToken.token;
|
|
19
|
+
}
|
|
20
|
+
const res = await this.fetchImpl(`${this.base}/pl/standard/user/oauth/authorize`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
23
|
+
body: new URLSearchParams({
|
|
24
|
+
grant_type: 'client_credentials',
|
|
25
|
+
client_id: this.opts.clientId,
|
|
26
|
+
client_secret: this.opts.clientSecret
|
|
27
|
+
})
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
throw new Error(`PayU OAuth failed: ${res.status}`);
|
|
31
|
+
}
|
|
32
|
+
const data = (await res.json());
|
|
33
|
+
if (!data.access_token)
|
|
34
|
+
throw new Error('PayU OAuth returned no access_token');
|
|
35
|
+
this.cachedToken = {
|
|
36
|
+
token: data.access_token,
|
|
37
|
+
expiresAt: now + (data.expires_in ?? 43200) * 1000
|
|
38
|
+
};
|
|
39
|
+
return data.access_token;
|
|
40
|
+
}
|
|
41
|
+
async createOrder(payload) {
|
|
42
|
+
const token = await this.getAccessToken();
|
|
43
|
+
const res = await this.fetchImpl(`${this.base}/api/v2_1/orders`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: `Bearer ${token}`,
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
Accept: 'application/json'
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(payload),
|
|
51
|
+
redirect: 'manual'
|
|
52
|
+
});
|
|
53
|
+
// PayU returns 302 by default with a JSON body (manual redirect is used to read it).
|
|
54
|
+
if (res.status !== 302 && !res.ok) {
|
|
55
|
+
throw new Error(`PayU createOrder failed: ${res.status}`);
|
|
56
|
+
}
|
|
57
|
+
const data = (await res.json());
|
|
58
|
+
if (data.status && data.status.statusCode && data.status.statusCode !== 'SUCCESS') {
|
|
59
|
+
throw new Error(`PayU createOrder status ${data.status.statusCode}`);
|
|
60
|
+
}
|
|
61
|
+
if (!data.redirectUri || !data.orderId) {
|
|
62
|
+
throw new Error('PayU createOrder missing redirectUri or orderId');
|
|
63
|
+
}
|
|
64
|
+
return { redirectUri: data.redirectUri, orderId: data.orderId };
|
|
65
|
+
}
|
|
66
|
+
async getOrder(payuOrderId) {
|
|
67
|
+
const token = await this.getAccessToken();
|
|
68
|
+
const res = await this.fetchImpl(`${this.base}/api/v2_1/orders/${encodeURIComponent(payuOrderId)}`, {
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${token}`,
|
|
71
|
+
Accept: 'application/json'
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok)
|
|
75
|
+
throw new Error(`PayU getOrder failed: ${res.status}`);
|
|
76
|
+
return res.json();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { I18nText, PaymentAdapter } from '../../types.js';
|
|
2
|
+
import { type PayuEnvironment } from './client.js';
|
|
3
|
+
export interface PayuAdapterOptions {
|
|
4
|
+
posId: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
clientSecret: string;
|
|
7
|
+
secondKey: string;
|
|
8
|
+
environment?: PayuEnvironment;
|
|
9
|
+
/**
|
|
10
|
+
* Absolute URL PayU will POST notifications to, e.g.
|
|
11
|
+
* `https://yourshop.com/api/shop/webhooks/payu`.
|
|
12
|
+
*/
|
|
13
|
+
notifyUrl: string;
|
|
14
|
+
/**
|
|
15
|
+
* Absolute URL template customer is redirected to after payment. Supports
|
|
16
|
+
* `{orderNumber}`, `{orderId}`, `{accessToken}`, `{language}`. If omitted,
|
|
17
|
+
* falls back to `shop.orderViewUrl` (must then be absolute).
|
|
18
|
+
*/
|
|
19
|
+
continueUrl?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
label?: I18nText;
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
export declare function payuAdapter(opts: PayuAdapterOptions): PaymentAdapter;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { getOrderById } from '../../server/orders.js';
|
|
2
|
+
import { requireShopConfig } from '../../server/db.js';
|
|
3
|
+
import { buildOrderViewUrl } from '../../server/order-access-url.js';
|
|
4
|
+
import { PayuClient } from './client.js';
|
|
5
|
+
import { buildPayuOrderPayload, splitFullName } from './payload.js';
|
|
6
|
+
import { verifyPayuSignature } from './signature.js';
|
|
7
|
+
import { mapPayuStatus } from './status-map.js';
|
|
8
|
+
const DEFAULT_LABEL = { pl: 'Płatność online (PayU)', en: 'Online payment (PayU)' };
|
|
9
|
+
export function payuAdapter(opts) {
|
|
10
|
+
const env = opts.environment ?? 'sandbox';
|
|
11
|
+
const client = new PayuClient({
|
|
12
|
+
clientId: opts.clientId,
|
|
13
|
+
clientSecret: opts.clientSecret,
|
|
14
|
+
environment: env,
|
|
15
|
+
fetch: opts.fetch
|
|
16
|
+
});
|
|
17
|
+
const id = opts.id ?? 'payu';
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
label: opts.label ?? DEFAULT_LABEL,
|
|
21
|
+
async createPayment(order, ctx) {
|
|
22
|
+
const shop = requireShopConfig();
|
|
23
|
+
const continueTemplate = opts.continueUrl ?? shop.orderViewUrl;
|
|
24
|
+
if (!/^https?:\/\//i.test(continueTemplate)) {
|
|
25
|
+
throw new Error('payuAdapter: continueUrl (or shop.orderViewUrl) must be an absolute URL.');
|
|
26
|
+
}
|
|
27
|
+
// Hydrate buyer details from stored order (customerName/phone not on OrderRef)
|
|
28
|
+
const full = await getOrderById(order.id);
|
|
29
|
+
const names = splitFullName(full?.customerName);
|
|
30
|
+
const continueUrl = buildOrderViewUrl(continueTemplate, {
|
|
31
|
+
orderNumber: order.number,
|
|
32
|
+
orderId: order.id,
|
|
33
|
+
accessToken: full?.accessToken ?? '',
|
|
34
|
+
language: ctx?.language ?? full?.language ?? null
|
|
35
|
+
});
|
|
36
|
+
const payload = buildPayuOrderPayload({
|
|
37
|
+
order,
|
|
38
|
+
posId: opts.posId,
|
|
39
|
+
notifyUrl: opts.notifyUrl,
|
|
40
|
+
continueUrl,
|
|
41
|
+
customerIp: ctx?.customerIp ?? '127.0.0.1',
|
|
42
|
+
buyer: {
|
|
43
|
+
firstName: names.firstName,
|
|
44
|
+
lastName: names.lastName,
|
|
45
|
+
phone: full?.customerPhone ?? undefined,
|
|
46
|
+
language: ctx?.language ?? full?.language ?? undefined
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const { redirectUri, orderId } = await client.createOrder(payload);
|
|
50
|
+
return {
|
|
51
|
+
status: 'redirect',
|
|
52
|
+
redirectUrl: redirectUri,
|
|
53
|
+
providerRef: orderId
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
async getStatus(providerRef) {
|
|
57
|
+
const data = (await client.getOrder(providerRef));
|
|
58
|
+
const first = Array.isArray(data.orders) ? data.orders[0] : undefined;
|
|
59
|
+
if (!first)
|
|
60
|
+
throw new Error('PayU getStatus: no order returned');
|
|
61
|
+
return {
|
|
62
|
+
orderNumber: first.extOrderId ?? '',
|
|
63
|
+
status: mapPayuStatus(first.status ?? 'PENDING'),
|
|
64
|
+
providerRef: first.orderId ?? providerRef,
|
|
65
|
+
raw: data
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
async handleWebhook(req) {
|
|
69
|
+
const rawBody = await req.text();
|
|
70
|
+
const headerValue = req.headers.get('OpenPayU-Signature') ?? req.headers.get('openpayu-signature');
|
|
71
|
+
if (!verifyPayuSignature(rawBody, headerValue, opts.secondKey)) {
|
|
72
|
+
throw new Error('PayU signature verification failed');
|
|
73
|
+
}
|
|
74
|
+
const parsed = JSON.parse(rawBody);
|
|
75
|
+
const extOrderId = parsed.order?.extOrderId;
|
|
76
|
+
const payuOrderId = parsed.order?.orderId ?? '';
|
|
77
|
+
const status = parsed.order?.status ?? 'PENDING';
|
|
78
|
+
if (!extOrderId)
|
|
79
|
+
throw new Error('PayU webhook missing extOrderId');
|
|
80
|
+
return {
|
|
81
|
+
orderNumber: extOrderId,
|
|
82
|
+
status: mapPayuStatus(status),
|
|
83
|
+
providerRef: payuOrderId,
|
|
84
|
+
raw: parsed
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { OrderRef } from '../../types.js';
|
|
2
|
+
export interface PayuBuyer {
|
|
3
|
+
email: string;
|
|
4
|
+
phone?: string;
|
|
5
|
+
firstName?: string;
|
|
6
|
+
lastName?: string;
|
|
7
|
+
language?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PayuOrderPayload {
|
|
10
|
+
notifyUrl: string;
|
|
11
|
+
continueUrl: string;
|
|
12
|
+
customerIp: string;
|
|
13
|
+
merchantPosId: string;
|
|
14
|
+
description: string;
|
|
15
|
+
currencyCode: string;
|
|
16
|
+
totalAmount: string;
|
|
17
|
+
extOrderId: string;
|
|
18
|
+
buyer: PayuBuyer;
|
|
19
|
+
products: Array<{
|
|
20
|
+
name: string;
|
|
21
|
+
unitPrice: string;
|
|
22
|
+
quantity: string;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
export interface BuildPayuPayloadInput {
|
|
26
|
+
order: OrderRef;
|
|
27
|
+
posId: string;
|
|
28
|
+
notifyUrl: string;
|
|
29
|
+
continueUrl: string;
|
|
30
|
+
customerIp: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
buyer?: Omit<PayuBuyer, 'email'> & {
|
|
33
|
+
email?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Split a single "Imię Nazwisko" string into first/last name heuristically.
|
|
38
|
+
* Anything after the first whitespace is lastName; if no space, everything is firstName.
|
|
39
|
+
*/
|
|
40
|
+
export declare function splitFullName(name: string | null | undefined): {
|
|
41
|
+
firstName?: string;
|
|
42
|
+
lastName?: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Build the order body expected by POST /api/v2_1/orders.
|
|
46
|
+
* Amounts are in integer minor units (grosze) — same as our stored totalGross.
|
|
47
|
+
*/
|
|
48
|
+
export declare function buildPayuOrderPayload(input: BuildPayuPayloadInput): PayuOrderPayload;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a single "Imię Nazwisko" string into first/last name heuristically.
|
|
3
|
+
* Anything after the first whitespace is lastName; if no space, everything is firstName.
|
|
4
|
+
*/
|
|
5
|
+
export function splitFullName(name) {
|
|
6
|
+
const trimmed = (name ?? '').trim();
|
|
7
|
+
if (!trimmed)
|
|
8
|
+
return {};
|
|
9
|
+
const firstSpace = trimmed.search(/\s/);
|
|
10
|
+
if (firstSpace === -1)
|
|
11
|
+
return { firstName: trimmed };
|
|
12
|
+
return {
|
|
13
|
+
firstName: trimmed.slice(0, firstSpace),
|
|
14
|
+
lastName: trimmed.slice(firstSpace + 1).trim() || undefined
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build the order body expected by POST /api/v2_1/orders.
|
|
19
|
+
* Amounts are in integer minor units (grosze) — same as our stored totalGross.
|
|
20
|
+
*/
|
|
21
|
+
export function buildPayuOrderPayload(input) {
|
|
22
|
+
const { order } = input;
|
|
23
|
+
const buyer = {
|
|
24
|
+
email: input.buyer?.email ?? order.customerEmail,
|
|
25
|
+
phone: input.buyer?.phone,
|
|
26
|
+
firstName: input.buyer?.firstName,
|
|
27
|
+
lastName: input.buyer?.lastName,
|
|
28
|
+
language: input.buyer?.language
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
notifyUrl: input.notifyUrl,
|
|
32
|
+
continueUrl: input.continueUrl,
|
|
33
|
+
customerIp: input.customerIp,
|
|
34
|
+
merchantPosId: input.posId,
|
|
35
|
+
description: input.description ?? `Order ${order.number}`,
|
|
36
|
+
currencyCode: order.currency,
|
|
37
|
+
totalAmount: String(order.totalGross),
|
|
38
|
+
extOrderId: order.number,
|
|
39
|
+
buyer,
|
|
40
|
+
products: [
|
|
41
|
+
{
|
|
42
|
+
name: `Order ${order.number}`,
|
|
43
|
+
unitPrice: String(order.totalGross),
|
|
44
|
+
quantity: '1'
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface ParsedSignatureHeader {
|
|
2
|
+
sender?: string;
|
|
3
|
+
signature?: string;
|
|
4
|
+
algorithm?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function parseSignatureHeader(header: string | null): ParsedSignatureHeader;
|
|
7
|
+
/**
|
|
8
|
+
* Verify a PayU webhook signature.
|
|
9
|
+
* Signature format (MD5): MD5(rawBody + secondKey) compared against header `signature=` value.
|
|
10
|
+
*/
|
|
11
|
+
export declare function verifyPayuSignature(rawBody: string, header: string | null, secondKey: string): boolean;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export function parseSignatureHeader(header) {
|
|
3
|
+
if (!header)
|
|
4
|
+
return {};
|
|
5
|
+
const parts = header.split(';');
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const part of parts) {
|
|
8
|
+
const [rawKey, rawValue] = part.split('=');
|
|
9
|
+
if (!rawKey || rawValue == null)
|
|
10
|
+
continue;
|
|
11
|
+
const key = rawKey.trim();
|
|
12
|
+
const value = rawValue.trim();
|
|
13
|
+
if (key === 'sender')
|
|
14
|
+
out.sender = value;
|
|
15
|
+
else if (key === 'signature')
|
|
16
|
+
out.signature = value;
|
|
17
|
+
else if (key === 'algorithm')
|
|
18
|
+
out.algorithm = value;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
function md5Hex(input) {
|
|
23
|
+
return createHash('md5').update(input, 'utf8').digest('hex');
|
|
24
|
+
}
|
|
25
|
+
function hexEqual(a, b) {
|
|
26
|
+
if (a.length !== b.length)
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Verify a PayU webhook signature.
|
|
37
|
+
* Signature format (MD5): MD5(rawBody + secondKey) compared against header `signature=` value.
|
|
38
|
+
*/
|
|
39
|
+
export function verifyPayuSignature(rawBody, header, secondKey) {
|
|
40
|
+
if (!secondKey)
|
|
41
|
+
return false;
|
|
42
|
+
const parsed = parseSignatureHeader(header);
|
|
43
|
+
if (!parsed.signature)
|
|
44
|
+
return false;
|
|
45
|
+
const algo = (parsed.algorithm ?? 'MD5').toUpperCase();
|
|
46
|
+
if (algo !== 'MD5')
|
|
47
|
+
return false;
|
|
48
|
+
const expected = md5Hex(rawBody + secondKey).toLowerCase();
|
|
49
|
+
return hexEqual(expected, parsed.signature.toLowerCase());
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function mapPayuStatus(status) {
|
|
2
|
+
switch (status.toUpperCase()) {
|
|
3
|
+
case 'COMPLETED':
|
|
4
|
+
return 'paid';
|
|
5
|
+
case 'CANCELED':
|
|
6
|
+
case 'REJECTED':
|
|
7
|
+
return 'paymentRejected';
|
|
8
|
+
case 'NEW':
|
|
9
|
+
case 'PENDING':
|
|
10
|
+
case 'WAITING_FOR_CONFIRMATION':
|
|
11
|
+
default:
|
|
12
|
+
return 'pending';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CartCookies } from './types.js';
|
|
2
|
+
export declare const ORDER_TOKEN_COOKIE_NAME = "aria_shop_order";
|
|
3
|
+
export interface OrderTokenCookieValue {
|
|
4
|
+
number: string;
|
|
5
|
+
token: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function writeOrderTokenCookie(cookies: CartCookies, value: OrderTokenCookieValue): void;
|
|
8
|
+
export declare function readOrderTokenCookie(cookies: CartCookies): OrderTokenCookieValue | null;
|
|
9
|
+
export declare function clearOrderTokenCookie(cookies: CartCookies): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const ORDER_TOKEN_COOKIE_NAME = 'aria_shop_order';
|
|
2
|
+
const MAX_AGE_SECONDS = 60 * 30;
|
|
3
|
+
function encode(value) {
|
|
4
|
+
const json = JSON.stringify(value);
|
|
5
|
+
return typeof btoa === 'function' ? btoa(json) : Buffer.from(json, 'utf8').toString('base64');
|
|
6
|
+
}
|
|
7
|
+
function decode(raw) {
|
|
8
|
+
try {
|
|
9
|
+
const json = typeof atob === 'function' ? atob(raw) : Buffer.from(raw, 'base64').toString('utf8');
|
|
10
|
+
const parsed = JSON.parse(json);
|
|
11
|
+
if (!parsed || typeof parsed !== 'object')
|
|
12
|
+
return null;
|
|
13
|
+
const number = parsed.number;
|
|
14
|
+
const token = parsed.token;
|
|
15
|
+
if (typeof number !== 'string' || typeof token !== 'string')
|
|
16
|
+
return null;
|
|
17
|
+
return { number, token };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function writeOrderTokenCookie(cookies, value) {
|
|
24
|
+
cookies.set(ORDER_TOKEN_COOKIE_NAME, encode(value), {
|
|
25
|
+
path: '/',
|
|
26
|
+
maxAge: MAX_AGE_SECONDS,
|
|
27
|
+
httpOnly: true,
|
|
28
|
+
sameSite: 'lax',
|
|
29
|
+
secure: false
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export function readOrderTokenCookie(cookies) {
|
|
33
|
+
const raw = cookies.get(ORDER_TOKEN_COOKIE_NAME);
|
|
34
|
+
if (!raw)
|
|
35
|
+
return null;
|
|
36
|
+
return decode(raw);
|
|
37
|
+
}
|
|
38
|
+
export function clearOrderTokenCookie(cookies) {
|
|
39
|
+
cookies.delete(ORDER_TOKEN_COOKIE_NAME, { path: '/' });
|
|
40
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CartSnapshot } from '../cart/types.js';
|
|
2
|
+
import type { OrderStatus } from '../types.js';
|
|
2
3
|
export interface ShopClientOptions {
|
|
3
4
|
baseUrl?: string;
|
|
4
5
|
fetch?: typeof fetch;
|
|
@@ -7,12 +8,14 @@ export interface ShippingMethodPublic {
|
|
|
7
8
|
id: string;
|
|
8
9
|
name: Record<string, string>;
|
|
9
10
|
description: Record<string, string> | null;
|
|
11
|
+
/** Netto w PLN (number, ≤6dp). Przed 0.15.2 było w groszach — od 0.15.2 w PLN. */
|
|
10
12
|
price: number;
|
|
11
13
|
vatRate: number;
|
|
12
14
|
carrierType: string;
|
|
13
15
|
conditions: {
|
|
14
16
|
freeAbove?: number;
|
|
15
17
|
} | null;
|
|
18
|
+
allowedPaymentMethods: string[] | null;
|
|
16
19
|
}
|
|
17
20
|
export interface CheckoutInput {
|
|
18
21
|
customerEmail: string;
|
|
@@ -32,9 +35,62 @@ export interface CheckoutInput {
|
|
|
32
35
|
export interface CheckoutResult {
|
|
33
36
|
orderId: string;
|
|
34
37
|
orderNumber: string;
|
|
35
|
-
|
|
38
|
+
accessToken: string;
|
|
39
|
+
status: OrderStatus;
|
|
36
40
|
totalGross: number;
|
|
37
41
|
currency: string;
|
|
42
|
+
paymentStatus: 'redirect' | 'manual' | 'error';
|
|
43
|
+
requiresPaymentRedirect: boolean;
|
|
44
|
+
redirectUrl: string | null;
|
|
45
|
+
}
|
|
46
|
+
export interface OrderDetailResponse {
|
|
47
|
+
order: {
|
|
48
|
+
id: string;
|
|
49
|
+
number: string;
|
|
50
|
+
status: OrderStatus;
|
|
51
|
+
currency: string;
|
|
52
|
+
customerEmail: string;
|
|
53
|
+
customerName: string | null;
|
|
54
|
+
customerPhone: string | null;
|
|
55
|
+
shippingAddress: Record<string, string> | null;
|
|
56
|
+
totalNet: number;
|
|
57
|
+
totalGross: number;
|
|
58
|
+
vatAmount: number;
|
|
59
|
+
shippingNet: number;
|
|
60
|
+
shippingGross: number;
|
|
61
|
+
paymentMethod: string | null;
|
|
62
|
+
carrierType: string | null;
|
|
63
|
+
carrierRef: string | null;
|
|
64
|
+
language: string | null;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
};
|
|
67
|
+
items: Array<{
|
|
68
|
+
id: string;
|
|
69
|
+
variantId: string | null;
|
|
70
|
+
nameSnapshot: {
|
|
71
|
+
product?: string;
|
|
72
|
+
variant?: string;
|
|
73
|
+
};
|
|
74
|
+
skuSnapshot: string | null;
|
|
75
|
+
priceNetSnapshot: number;
|
|
76
|
+
priceGrossSnapshot: number;
|
|
77
|
+
vatRate: number;
|
|
78
|
+
qty: number;
|
|
79
|
+
}>;
|
|
80
|
+
statusHistory: Array<{
|
|
81
|
+
status: OrderStatus;
|
|
82
|
+
note: string | null;
|
|
83
|
+
changedAt: string;
|
|
84
|
+
}>;
|
|
85
|
+
}
|
|
86
|
+
export interface RefreshPaymentResult {
|
|
87
|
+
status: OrderStatus;
|
|
88
|
+
updated?: boolean;
|
|
89
|
+
noop?: boolean;
|
|
90
|
+
}
|
|
91
|
+
export interface RetryPaymentResult {
|
|
92
|
+
status: OrderStatus;
|
|
93
|
+
paymentStatus: 'redirect' | 'manual' | 'error';
|
|
38
94
|
requiresPaymentRedirect: boolean;
|
|
39
95
|
redirectUrl: string | null;
|
|
40
96
|
}
|
|
@@ -54,6 +110,13 @@ export interface ShopClient {
|
|
|
54
110
|
checkout: {
|
|
55
111
|
submit(input: CheckoutInput): Promise<CheckoutResult>;
|
|
56
112
|
};
|
|
113
|
+
orders: {
|
|
114
|
+
get(orderNumber: string, token?: string): Promise<OrderDetailResponse>;
|
|
115
|
+
refreshPayment(orderNumber: string, token?: string): Promise<RefreshPaymentResult>;
|
|
116
|
+
retryPayment(orderNumber: string, token?: string): Promise<RetryPaymentResult>;
|
|
117
|
+
};
|
|
57
118
|
}
|
|
58
119
|
export declare function createShopClient(options?: ShopClientOptions): ShopClient;
|
|
59
120
|
export type { CartSnapshot, CartLine, CartItemRef } from '../cart/types.js';
|
|
121
|
+
export { createOrderState } from './use-order.svelte.js';
|
|
122
|
+
export type { OrderState, CreateOrderStateOptions, OrderPaymentPhase } from './use-order.svelte.js';
|
|
@@ -35,6 +35,15 @@ export function createShopClient(options = {}) {
|
|
|
35
35
|
},
|
|
36
36
|
checkout: {
|
|
37
37
|
submit: (input) => call('POST', '/api/shop/checkout', input)
|
|
38
|
+
},
|
|
39
|
+
orders: {
|
|
40
|
+
get: (orderNumber, token) => call('GET', `/api/shop/orders/${encodeURIComponent(orderNumber)}${tokenQuery(token)}`),
|
|
41
|
+
refreshPayment: (orderNumber, token) => call('POST', `/api/shop/orders/${encodeURIComponent(orderNumber)}/refresh-payment${tokenQuery(token)}`),
|
|
42
|
+
retryPayment: (orderNumber, token) => call('POST', `/api/shop/orders/${encodeURIComponent(orderNumber)}/retry-payment${tokenQuery(token)}`)
|
|
38
43
|
}
|
|
39
44
|
};
|
|
40
45
|
}
|
|
46
|
+
function tokenQuery(token) {
|
|
47
|
+
return token ? `?token=${encodeURIComponent(token)}` : '';
|
|
48
|
+
}
|
|
49
|
+
export { createOrderState } from './use-order.svelte.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type OrderDetailResponse, type ShopClient } from './index.js';
|
|
2
|
+
export type OrderPaymentPhase = 'idle' | 'polling' | 'retrying' | 'refreshing';
|
|
3
|
+
export interface CreateOrderStateOptions {
|
|
4
|
+
number: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
initialData?: OrderDetailResponse | null;
|
|
7
|
+
client?: ShopClient;
|
|
8
|
+
pollIntervalMs?: number;
|
|
9
|
+
pollTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
declare class OrderStateImpl {
|
|
12
|
+
private readonly opts;
|
|
13
|
+
data: OrderDetailResponse | null;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error: Error | null;
|
|
16
|
+
phase: OrderPaymentPhase;
|
|
17
|
+
private readonly client;
|
|
18
|
+
private pollTimer;
|
|
19
|
+
private pollStopAt;
|
|
20
|
+
constructor(opts: CreateOrderStateOptions);
|
|
21
|
+
get status(): OrderDetailResponse['order']['status'] | null;
|
|
22
|
+
load(): Promise<void>;
|
|
23
|
+
refreshPayment(): Promise<void>;
|
|
24
|
+
/** Returns the redirect URL if the adapter produced one — caller is responsible for navigating. */
|
|
25
|
+
retry(): Promise<string | null>;
|
|
26
|
+
startPolling(): void;
|
|
27
|
+
stopPolling(): void;
|
|
28
|
+
dispose(): void;
|
|
29
|
+
}
|
|
30
|
+
export type OrderState = OrderStateImpl;
|
|
31
|
+
export declare function createOrderState(opts: CreateOrderStateOptions): OrderState;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createShopClient } from './index.js';
|
|
2
|
+
const DEFAULT_POLL_INTERVAL = 5000;
|
|
3
|
+
const DEFAULT_POLL_TIMEOUT = 120_000;
|
|
4
|
+
const POLLING_STATUSES = new Set(['new', 'awaitingPayment']);
|
|
5
|
+
class OrderStateImpl {
|
|
6
|
+
opts;
|
|
7
|
+
data = $state(null);
|
|
8
|
+
loading = $state(false);
|
|
9
|
+
error = $state(null);
|
|
10
|
+
phase = $state('idle');
|
|
11
|
+
client;
|
|
12
|
+
pollTimer = null;
|
|
13
|
+
pollStopAt = 0;
|
|
14
|
+
constructor(opts) {
|
|
15
|
+
this.opts = opts;
|
|
16
|
+
this.client = opts.client ?? createShopClient();
|
|
17
|
+
if (opts.initialData)
|
|
18
|
+
this.data = opts.initialData;
|
|
19
|
+
}
|
|
20
|
+
get status() {
|
|
21
|
+
return this.data?.order.status ?? null;
|
|
22
|
+
}
|
|
23
|
+
async load() {
|
|
24
|
+
this.loading = true;
|
|
25
|
+
this.error = null;
|
|
26
|
+
try {
|
|
27
|
+
this.data = await this.client.orders.get(this.opts.number, this.opts.token);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
this.error = err instanceof Error ? err : new Error(String(err));
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
this.loading = false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async refreshPayment() {
|
|
37
|
+
this.phase = 'refreshing';
|
|
38
|
+
try {
|
|
39
|
+
const result = await this.client.orders.refreshPayment(this.opts.number, this.opts.token);
|
|
40
|
+
if (result.updated || (this.data && result.status !== this.data.order.status)) {
|
|
41
|
+
await this.load();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
this.error = err instanceof Error ? err : new Error(String(err));
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
this.phase = 'idle';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Returns the redirect URL if the adapter produced one — caller is responsible for navigating. */
|
|
52
|
+
async retry() {
|
|
53
|
+
this.phase = 'retrying';
|
|
54
|
+
this.error = null;
|
|
55
|
+
try {
|
|
56
|
+
const result = await this.client.orders.retryPayment(this.opts.number, this.opts.token);
|
|
57
|
+
if (result.requiresPaymentRedirect && result.redirectUrl) {
|
|
58
|
+
return result.redirectUrl;
|
|
59
|
+
}
|
|
60
|
+
await this.load();
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
this.error = err instanceof Error ? err : new Error(String(err));
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
this.phase = 'idle';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
startPolling() {
|
|
72
|
+
if (this.pollTimer)
|
|
73
|
+
return;
|
|
74
|
+
const interval = this.opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL;
|
|
75
|
+
const timeout = this.opts.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT;
|
|
76
|
+
this.pollStopAt = Date.now() + timeout;
|
|
77
|
+
this.phase = 'polling';
|
|
78
|
+
this.pollTimer = setInterval(async () => {
|
|
79
|
+
if (Date.now() >= this.pollStopAt) {
|
|
80
|
+
this.stopPolling();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const current = this.data?.order.status;
|
|
84
|
+
if (!current || !POLLING_STATUSES.has(current)) {
|
|
85
|
+
this.stopPolling();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await this.refreshPayment();
|
|
89
|
+
}, interval);
|
|
90
|
+
}
|
|
91
|
+
stopPolling() {
|
|
92
|
+
if (this.pollTimer) {
|
|
93
|
+
clearInterval(this.pollTimer);
|
|
94
|
+
this.pollTimer = null;
|
|
95
|
+
}
|
|
96
|
+
if (this.phase === 'polling')
|
|
97
|
+
this.phase = 'idle';
|
|
98
|
+
}
|
|
99
|
+
dispose() {
|
|
100
|
+
this.stopPolling();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function createOrderState(opts) {
|
|
104
|
+
return new OrderStateImpl(opts);
|
|
105
|
+
}
|