includio-cms 0.24.0 → 0.25.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/API.md +29 -6
- package/CHANGELOG.md +95 -0
- package/DOCS.md +80 -5
- package/ROADMAP.md +1 -0
- package/dist/admin/client/index.d.ts +3 -0
- package/dist/admin/client/index.js +3 -0
- package/dist/admin/client/shop/coupon-edit-page.svelte +44 -0
- package/dist/admin/client/shop/coupon-edit-page.svelte.d.ts +3 -0
- package/dist/admin/client/shop/coupon-form.svelte +170 -0
- package/dist/admin/client/shop/coupon-form.svelte.d.ts +18 -0
- package/dist/admin/client/shop/coupon-new-page.svelte +25 -0
- package/dist/admin/client/shop/coupon-new-page.svelte.d.ts +18 -0
- package/dist/admin/client/shop/coupons-list-page.svelte +135 -0
- package/dist/admin/client/shop/coupons-list-page.svelte.d.ts +3 -0
- package/dist/admin/client/shop/refund-dialog.svelte +161 -0
- package/dist/admin/client/shop/refund-dialog.svelte.d.ts +11 -0
- package/dist/admin/client/shop/shipping-method-edit-page.svelte +3 -6
- package/dist/admin/client/shop/shipping-method-form.svelte +15 -21
- package/dist/admin/client/shop/shipping-method-new-page.svelte +3 -6
- package/dist/admin/client/shop/shipping-methods-list-page.svelte +6 -6
- package/dist/admin/client/shop/shop-order-detail-page.svelte +107 -27
- package/dist/admin/client/shop/shop-orders-list-page.svelte +49 -11
- package/dist/admin/client/shop/shop-products-list-page.svelte +12 -11
- package/dist/admin/components/layout/lang.d.ts +1 -0
- package/dist/admin/components/layout/lang.js +4 -2
- package/dist/admin/components/layout/layout-renderer.svelte +12 -11
- package/dist/admin/components/layout/nav-breadcrumbs.svelte +3 -5
- package/dist/admin/components/layout/nav-shop.svelte +3 -1
- package/dist/admin/components/layout/nav-user.svelte +6 -4
- package/dist/admin/components/layout/site-header.svelte +11 -5
- package/dist/admin/remote/shop.remote.d.ts +122 -3
- package/dist/admin/remote/shop.remote.js +161 -5
- package/dist/db-postgres/schema/shop/couponRedemptions.d.ts +97 -0
- package/dist/db-postgres/schema/shop/couponRedemptions.js +21 -0
- package/dist/db-postgres/schema/shop/coupons.d.ts +197 -0
- package/dist/db-postgres/schema/shop/coupons.js +18 -0
- package/dist/db-postgres/schema/shop/index.d.ts +4 -0
- package/dist/db-postgres/schema/shop/index.js +4 -0
- package/dist/db-postgres/schema/shop/product.d.ts +17 -0
- package/dist/db-postgres/schema/shop/product.js +2 -0
- package/dist/db-postgres/schema/shop/refunds.d.ts +214 -0
- package/dist/db-postgres/schema/shop/refunds.js +21 -0
- package/dist/db-postgres/schema/shop/webhookEvents.d.ts +183 -0
- package/dist/db-postgres/schema/shop/webhookEvents.js +22 -0
- package/dist/shop/adapters/payu/client.d.ts +9 -0
- package/dist/shop/adapters/payu/client.js +29 -0
- package/dist/shop/adapters/payu/index.js +17 -1
- package/dist/shop/adapters/stripe/index.d.ts +64 -0
- package/dist/shop/adapters/stripe/index.js +169 -0
- package/dist/shop/adapters/stripe/payload.d.ts +38 -0
- package/dist/shop/adapters/stripe/payload.js +90 -0
- package/dist/shop/adapters/stripe/status-map.d.ts +11 -0
- package/dist/shop/adapters/stripe/status-map.js +31 -0
- package/dist/shop/cart/coupon-cookie.d.ts +7 -0
- package/dist/shop/cart/coupon-cookie.js +32 -0
- package/dist/shop/cart/types.d.ts +12 -0
- package/dist/shop/client/index.d.ts +118 -0
- package/dist/shop/client/index.js +39 -1
- package/dist/shop/http/cart-handler.d.ts +8 -0
- package/dist/shop/http/cart-handler.js +60 -1
- package/dist/shop/http/checkout-handler.js +7 -3
- package/dist/shop/http/index.d.ts +1 -1
- package/dist/shop/http/index.js +1 -1
- package/dist/shop/http/retry-payment-handler.js +1 -1
- package/dist/shop/http/webhook-handler.js +19 -1
- package/dist/shop/http/webhook-idempotency.d.ts +16 -0
- package/dist/shop/http/webhook-idempotency.js +51 -0
- package/dist/shop/http/webhook-logic.js +2 -1
- package/dist/shop/index.d.ts +3 -1
- package/dist/shop/index.js +3 -1
- package/dist/shop/pricing.d.ts +15 -0
- package/dist/shop/pricing.js +22 -0
- package/dist/shop/server/cart-hydrate.d.ts +1 -0
- package/dist/shop/server/cart-hydrate.js +58 -10
- package/dist/shop/server/coupons.d.ts +53 -0
- package/dist/shop/server/coupons.js +117 -0
- package/dist/shop/server/email.d.ts +15 -0
- package/dist/shop/server/email.js +46 -3
- package/dist/shop/server/orders.d.ts +1 -0
- package/dist/shop/server/orders.js +120 -54
- package/dist/shop/server/refund.d.ts +32 -0
- package/dist/shop/server/refund.js +140 -0
- package/dist/shop/svelte/InpostPicker.svelte +4 -7
- package/dist/shop/svelte/OrderStatus.svelte +6 -10
- package/dist/shop/svelte/labels.js +4 -2
- package/dist/shop/types.d.ts +41 -1
- package/dist/updates/0.25.0/index.d.ts +2 -0
- package/dist/updates/0.25.0/index.js +89 -0
- package/dist/updates/index.js +64 -1
- package/package.json +6 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { requireShopConfig } from '../../server/db.js';
|
|
2
|
+
import { getOrderById } from '../../server/orders.js';
|
|
3
|
+
import { buildOrderViewUrl } from '../../server/order-access-url.js';
|
|
4
|
+
import { buildStripeCheckoutPayload } from './payload.js';
|
|
5
|
+
import { mapStripeEventType, mapStripeSessionStatus } from './status-map.js';
|
|
6
|
+
const DEFAULT_LABEL = { pl: 'Karta płatnicza (Stripe)', en: 'Card payment (Stripe)' };
|
|
7
|
+
const DEFAULT_PRODUCT_NAME = { pl: 'Zamówienie {number}', en: 'Order {number}' };
|
|
8
|
+
const DEFAULT_API_VERSION = '2024-11-20.acacia';
|
|
9
|
+
function pickI18n(text, language, fallback = 'pl') {
|
|
10
|
+
if (language && text[language])
|
|
11
|
+
return text[language];
|
|
12
|
+
return text[fallback] ?? Object.values(text)[0] ?? '';
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Stripe payment adapter (Checkout Session flow).
|
|
16
|
+
*
|
|
17
|
+
* `stripe` is an **optional peer dependency** — install it in your project
|
|
18
|
+
* (`pnpm add stripe`) when using this adapter. The SDK is lazy-loaded on first
|
|
19
|
+
* `createPayment()` / `getStatus()` / `refund()` / `handleWebhook()` call;
|
|
20
|
+
* a missing peer throws a clear error.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { stripeAdapter } from 'includio-cms/shop';
|
|
26
|
+
*
|
|
27
|
+
* const stripe = stripeAdapter({
|
|
28
|
+
* secretKey: process.env.STRIPE_SECRET_KEY!,
|
|
29
|
+
* webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
30
|
+
* successUrl: 'https://example.com/shop/order/{orderNumber}?token={accessToken}',
|
|
31
|
+
* cancelUrl: 'https://example.com/shop/cancelled'
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function stripeAdapter(opts) {
|
|
36
|
+
const id = opts.id ?? 'stripe';
|
|
37
|
+
const apiVersion = opts.apiVersion ?? DEFAULT_API_VERSION;
|
|
38
|
+
let cachedClient = null;
|
|
39
|
+
let cachedStripeNs = null;
|
|
40
|
+
async function loadStripeNs() {
|
|
41
|
+
if (cachedStripeNs)
|
|
42
|
+
return cachedStripeNs;
|
|
43
|
+
const importer = opts.stripeImport ?? (() => import('stripe'));
|
|
44
|
+
try {
|
|
45
|
+
cachedStripeNs = await importer();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error('stripe SDK is required for stripeAdapter — install it with `pnpm add stripe`');
|
|
49
|
+
}
|
|
50
|
+
return cachedStripeNs;
|
|
51
|
+
}
|
|
52
|
+
async function getClient() {
|
|
53
|
+
if (cachedClient)
|
|
54
|
+
return cachedClient;
|
|
55
|
+
const ns = await loadStripeNs();
|
|
56
|
+
const Ctor = ns.default;
|
|
57
|
+
cachedClient = new Ctor(opts.secretKey, {
|
|
58
|
+
apiVersion: apiVersion
|
|
59
|
+
});
|
|
60
|
+
return cachedClient;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
id,
|
|
64
|
+
label: opts.label ?? DEFAULT_LABEL,
|
|
65
|
+
async createPayment(order, ctx) {
|
|
66
|
+
const shop = requireShopConfig();
|
|
67
|
+
const successTemplate = opts.successUrl ?? shop.orderViewUrl;
|
|
68
|
+
const cancelTemplate = opts.cancelUrl ?? successTemplate;
|
|
69
|
+
if (!/^https?:\/\//i.test(successTemplate)) {
|
|
70
|
+
throw new Error('stripeAdapter: successUrl (or shop.orderViewUrl) must be an absolute URL.');
|
|
71
|
+
}
|
|
72
|
+
if (!/^https?:\/\//i.test(cancelTemplate)) {
|
|
73
|
+
throw new Error('stripeAdapter: cancelUrl must be an absolute URL.');
|
|
74
|
+
}
|
|
75
|
+
const full = await getOrderById(order.id);
|
|
76
|
+
const language = ctx?.language ?? full?.language ?? null;
|
|
77
|
+
const successUrl = buildOrderViewUrl(successTemplate, {
|
|
78
|
+
orderNumber: order.number,
|
|
79
|
+
orderId: order.id,
|
|
80
|
+
accessToken: full?.accessToken ?? '',
|
|
81
|
+
language
|
|
82
|
+
});
|
|
83
|
+
const cancelUrl = buildOrderViewUrl(cancelTemplate, {
|
|
84
|
+
orderNumber: order.number,
|
|
85
|
+
orderId: order.id,
|
|
86
|
+
accessToken: full?.accessToken ?? '',
|
|
87
|
+
language
|
|
88
|
+
});
|
|
89
|
+
const productNameTpl = pickI18n(opts.productName ?? DEFAULT_PRODUCT_NAME, language);
|
|
90
|
+
const productName = productNameTpl.replace('{number}', order.number);
|
|
91
|
+
const payload = buildStripeCheckoutPayload({
|
|
92
|
+
order,
|
|
93
|
+
successUrl,
|
|
94
|
+
cancelUrl,
|
|
95
|
+
productName,
|
|
96
|
+
language
|
|
97
|
+
});
|
|
98
|
+
const client = await getClient();
|
|
99
|
+
const session = await client.checkout.sessions.create(payload);
|
|
100
|
+
if (!session.url) {
|
|
101
|
+
throw new Error('Stripe Checkout Session created without redirect URL.');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
status: 'redirect',
|
|
105
|
+
redirectUrl: session.url,
|
|
106
|
+
providerRef: session.id
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
async getStatus(providerRef) {
|
|
110
|
+
const client = await getClient();
|
|
111
|
+
const session = await client.checkout.sessions.retrieve(providerRef);
|
|
112
|
+
const orderNumber = session.metadata?.orderNumber ?? session.client_reference_id ?? '';
|
|
113
|
+
return {
|
|
114
|
+
orderNumber,
|
|
115
|
+
status: mapStripeSessionStatus(session.status, session.payment_status),
|
|
116
|
+
providerRef: session.id,
|
|
117
|
+
raw: session
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
async handleWebhook(req) {
|
|
121
|
+
const rawBody = await req.text();
|
|
122
|
+
const signature = req.headers.get('stripe-signature');
|
|
123
|
+
if (!signature)
|
|
124
|
+
throw new Error('Stripe webhook missing Stripe-Signature header');
|
|
125
|
+
const ns = await loadStripeNs();
|
|
126
|
+
const client = await getClient();
|
|
127
|
+
let event;
|
|
128
|
+
try {
|
|
129
|
+
event = client.webhooks.constructEvent(rawBody, signature, opts.webhookSecret);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
throw new Error(`Stripe webhook signature verification failed: ${err.message}`);
|
|
133
|
+
}
|
|
134
|
+
void ns; // satisfy unused warning when stripeImport mocks `default`
|
|
135
|
+
const session = event.data?.object ?? null;
|
|
136
|
+
const orderNumber = session?.metadata?.orderNumber ?? session?.client_reference_id ?? '';
|
|
137
|
+
const mapped = mapStripeEventType(event.type, session?.status ?? null, session?.payment_status ?? null);
|
|
138
|
+
return {
|
|
139
|
+
orderNumber,
|
|
140
|
+
status: mapped ?? 'pending',
|
|
141
|
+
providerRef: session?.id ?? event.id,
|
|
142
|
+
raw: event,
|
|
143
|
+
eventId: event.id,
|
|
144
|
+
eventType: event.type
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
async refund(input) {
|
|
148
|
+
const client = await getClient();
|
|
149
|
+
// providerRef is the Checkout Session id; resolve to PaymentIntent for refund.
|
|
150
|
+
const session = await client.checkout.sessions.retrieve(input.providerRef);
|
|
151
|
+
const paymentIntent = typeof session.payment_intent === 'string'
|
|
152
|
+
? session.payment_intent
|
|
153
|
+
: (session.payment_intent?.id ?? null);
|
|
154
|
+
if (!paymentIntent) {
|
|
155
|
+
throw new Error(`Stripe refund: no payment_intent attached to session ${input.providerRef}`);
|
|
156
|
+
}
|
|
157
|
+
const refund = await client.refunds.create({
|
|
158
|
+
payment_intent: paymentIntent,
|
|
159
|
+
...(input.amount !== undefined ? { amount: input.amount } : {}),
|
|
160
|
+
...(input.reason ? { metadata: { reason: input.reason } } : {})
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
providerRef: refund.id,
|
|
164
|
+
amount: typeof refund.amount === 'number' ? refund.amount : (input.amount ?? 0),
|
|
165
|
+
raw: refund
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { OrderRef } from '../../types.js';
|
|
2
|
+
export interface StripeCheckoutPayload {
|
|
3
|
+
mode: 'payment';
|
|
4
|
+
line_items: Array<{
|
|
5
|
+
price_data: {
|
|
6
|
+
currency: string;
|
|
7
|
+
product_data: {
|
|
8
|
+
name: string;
|
|
9
|
+
};
|
|
10
|
+
unit_amount: number;
|
|
11
|
+
};
|
|
12
|
+
quantity: number;
|
|
13
|
+
}>;
|
|
14
|
+
success_url: string;
|
|
15
|
+
cancel_url: string;
|
|
16
|
+
customer_email?: string;
|
|
17
|
+
client_reference_id: string;
|
|
18
|
+
metadata: Record<string, string>;
|
|
19
|
+
payment_intent_data?: {
|
|
20
|
+
metadata: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
locale?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function normalizeStripeLocale(input: string | null | undefined): string | undefined;
|
|
25
|
+
export interface BuildStripeCheckoutPayloadInput {
|
|
26
|
+
order: OrderRef;
|
|
27
|
+
successUrl: string;
|
|
28
|
+
cancelUrl: string;
|
|
29
|
+
productName: string;
|
|
30
|
+
language?: string | null;
|
|
31
|
+
metadata?: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build a Checkout Session create payload representing the order as a single
|
|
35
|
+
* line item (full gross). We intentionally do NOT itemise — the order snapshot
|
|
36
|
+
* already lives in our DB; Stripe's job is collect payment for `totalGross`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildStripeCheckoutPayload(input: BuildStripeCheckoutPayloadInput): StripeCheckoutPayload;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// All entries lowercase; we normalize input to lowercase before lookup.
|
|
2
|
+
const STRIPE_LOCALES = new Set([
|
|
3
|
+
'auto',
|
|
4
|
+
'bg',
|
|
5
|
+
'cs',
|
|
6
|
+
'da',
|
|
7
|
+
'de',
|
|
8
|
+
'el',
|
|
9
|
+
'en',
|
|
10
|
+
'en-gb',
|
|
11
|
+
'es',
|
|
12
|
+
'es-419',
|
|
13
|
+
'et',
|
|
14
|
+
'fi',
|
|
15
|
+
'fil',
|
|
16
|
+
'fr',
|
|
17
|
+
'fr-ca',
|
|
18
|
+
'hr',
|
|
19
|
+
'hu',
|
|
20
|
+
'id',
|
|
21
|
+
'it',
|
|
22
|
+
'ja',
|
|
23
|
+
'ko',
|
|
24
|
+
'lt',
|
|
25
|
+
'lv',
|
|
26
|
+
'ms',
|
|
27
|
+
'mt',
|
|
28
|
+
'nb',
|
|
29
|
+
'nl',
|
|
30
|
+
'pl',
|
|
31
|
+
'pt',
|
|
32
|
+
'pt-br',
|
|
33
|
+
'ro',
|
|
34
|
+
'ru',
|
|
35
|
+
'sk',
|
|
36
|
+
'sl',
|
|
37
|
+
'sv',
|
|
38
|
+
'th',
|
|
39
|
+
'tr',
|
|
40
|
+
'vi',
|
|
41
|
+
'zh',
|
|
42
|
+
'zh-hk',
|
|
43
|
+
'zh-tw'
|
|
44
|
+
]);
|
|
45
|
+
export function normalizeStripeLocale(input) {
|
|
46
|
+
if (!input)
|
|
47
|
+
return undefined;
|
|
48
|
+
const lower = input.toLowerCase();
|
|
49
|
+
if (STRIPE_LOCALES.has(lower))
|
|
50
|
+
return lower;
|
|
51
|
+
const root = lower.split('-')[0];
|
|
52
|
+
if (STRIPE_LOCALES.has(root))
|
|
53
|
+
return root;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build a Checkout Session create payload representing the order as a single
|
|
58
|
+
* line item (full gross). We intentionally do NOT itemise — the order snapshot
|
|
59
|
+
* already lives in our DB; Stripe's job is collect payment for `totalGross`.
|
|
60
|
+
*/
|
|
61
|
+
export function buildStripeCheckoutPayload(input) {
|
|
62
|
+
const metadata = {
|
|
63
|
+
orderNumber: input.order.number,
|
|
64
|
+
orderId: input.order.id,
|
|
65
|
+
...input.metadata
|
|
66
|
+
};
|
|
67
|
+
const payload = {
|
|
68
|
+
mode: 'payment',
|
|
69
|
+
line_items: [
|
|
70
|
+
{
|
|
71
|
+
price_data: {
|
|
72
|
+
currency: input.order.currency.toLowerCase(),
|
|
73
|
+
product_data: { name: input.productName },
|
|
74
|
+
unit_amount: input.order.totalGross
|
|
75
|
+
},
|
|
76
|
+
quantity: 1
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
success_url: input.successUrl,
|
|
80
|
+
cancel_url: input.cancelUrl,
|
|
81
|
+
customer_email: input.order.customerEmail,
|
|
82
|
+
client_reference_id: input.order.number,
|
|
83
|
+
metadata,
|
|
84
|
+
payment_intent_data: { metadata }
|
|
85
|
+
};
|
|
86
|
+
const locale = normalizeStripeLocale(input.language);
|
|
87
|
+
if (locale)
|
|
88
|
+
payload.locale = locale;
|
|
89
|
+
return payload;
|
|
90
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PaymentEvent } from '../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Map a Stripe Checkout Session payment_status (paid / unpaid / no_payment_required)
|
|
4
|
+
* combined with session.status (open / complete / expired) into our PaymentEvent.status.
|
|
5
|
+
*/
|
|
6
|
+
export declare function mapStripeSessionStatus(sessionStatus: string | null | undefined, paymentStatus: string | null | undefined): PaymentEvent['status'];
|
|
7
|
+
/**
|
|
8
|
+
* Map a Stripe webhook event type to a PaymentEvent.status.
|
|
9
|
+
* Returns null when the event is not relevant to order status (e.g. fulfilment-only).
|
|
10
|
+
*/
|
|
11
|
+
export declare function mapStripeEventType(eventType: string, sessionStatus: string | null | undefined, paymentStatus: string | null | undefined): PaymentEvent['status'] | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map a Stripe Checkout Session payment_status (paid / unpaid / no_payment_required)
|
|
3
|
+
* combined with session.status (open / complete / expired) into our PaymentEvent.status.
|
|
4
|
+
*/
|
|
5
|
+
export function mapStripeSessionStatus(sessionStatus, paymentStatus) {
|
|
6
|
+
if (sessionStatus === 'expired')
|
|
7
|
+
return 'paymentRejected';
|
|
8
|
+
if (sessionStatus === 'complete') {
|
|
9
|
+
if (paymentStatus === 'paid' || paymentStatus === 'no_payment_required')
|
|
10
|
+
return 'paid';
|
|
11
|
+
// async pending (e.g. SEPA) — keep order in pending until follow-up event
|
|
12
|
+
return 'pending';
|
|
13
|
+
}
|
|
14
|
+
return 'pending';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Map a Stripe webhook event type to a PaymentEvent.status.
|
|
18
|
+
* Returns null when the event is not relevant to order status (e.g. fulfilment-only).
|
|
19
|
+
*/
|
|
20
|
+
export function mapStripeEventType(eventType, sessionStatus, paymentStatus) {
|
|
21
|
+
switch (eventType) {
|
|
22
|
+
case 'checkout.session.completed':
|
|
23
|
+
case 'checkout.session.async_payment_succeeded':
|
|
24
|
+
return mapStripeSessionStatus(sessionStatus, paymentStatus);
|
|
25
|
+
case 'checkout.session.expired':
|
|
26
|
+
case 'checkout.session.async_payment_failed':
|
|
27
|
+
return 'paymentRejected';
|
|
28
|
+
default:
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CartCookies } from './types.js';
|
|
2
|
+
export declare const COUPON_COOKIE_NAME = "aria_shop_coupon";
|
|
3
|
+
export declare function normalizeCouponCode(input: string): string;
|
|
4
|
+
export declare function isValidCouponCode(code: string): boolean;
|
|
5
|
+
export declare function readCouponCookie(cookies: CartCookies): string | null;
|
|
6
|
+
export declare function writeCouponCookie(cookies: CartCookies, code: string): void;
|
|
7
|
+
export declare function clearCouponCookie(cookies: CartCookies): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const COUPON_COOKIE_NAME = 'aria_shop_coupon';
|
|
2
|
+
const MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days — shorter than cart TTL by design
|
|
3
|
+
const MAX_LEN = 64;
|
|
4
|
+
const VALID = /^[A-Z0-9_-]{1,64}$/;
|
|
5
|
+
export function normalizeCouponCode(input) {
|
|
6
|
+
return input.trim().toUpperCase();
|
|
7
|
+
}
|
|
8
|
+
export function isValidCouponCode(code) {
|
|
9
|
+
return code.length > 0 && code.length <= MAX_LEN && VALID.test(code);
|
|
10
|
+
}
|
|
11
|
+
export function readCouponCookie(cookies) {
|
|
12
|
+
const raw = cookies.get(COUPON_COOKIE_NAME);
|
|
13
|
+
if (!raw)
|
|
14
|
+
return null;
|
|
15
|
+
const code = normalizeCouponCode(raw);
|
|
16
|
+
return isValidCouponCode(code) ? code : null;
|
|
17
|
+
}
|
|
18
|
+
export function writeCouponCookie(cookies, code) {
|
|
19
|
+
const normalized = normalizeCouponCode(code);
|
|
20
|
+
if (!isValidCouponCode(normalized))
|
|
21
|
+
return;
|
|
22
|
+
cookies.set(COUPON_COOKIE_NAME, normalized, {
|
|
23
|
+
path: '/',
|
|
24
|
+
maxAge: MAX_AGE_SECONDS,
|
|
25
|
+
httpOnly: false,
|
|
26
|
+
sameSite: 'lax',
|
|
27
|
+
secure: false
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function clearCouponCookie(cookies) {
|
|
31
|
+
cookies.delete(COUPON_COOKIE_NAME, { path: '/' });
|
|
32
|
+
}
|
|
@@ -19,13 +19,25 @@ export interface CartLine extends CartItemRef {
|
|
|
19
19
|
available: boolean;
|
|
20
20
|
issue?: 'not-found' | 'inactive' | 'out-of-stock' | 'qty-adjusted';
|
|
21
21
|
}
|
|
22
|
+
export interface AppliedCoupon {
|
|
23
|
+
code: string;
|
|
24
|
+
type: 'percent' | 'fixed';
|
|
25
|
+
value: number;
|
|
26
|
+
discountNet: number;
|
|
27
|
+
discountGross: number;
|
|
28
|
+
}
|
|
22
29
|
export interface CartSnapshot {
|
|
23
30
|
items: CartLine[];
|
|
24
31
|
itemCount: number;
|
|
32
|
+
/** Subtotal before coupon (sum of lines). When no coupon, equals totalNet. */
|
|
33
|
+
subtotalNet: number;
|
|
34
|
+
subtotalGross: number;
|
|
35
|
+
subtotalVat: number;
|
|
25
36
|
totalNet: number;
|
|
26
37
|
totalGross: number;
|
|
27
38
|
totalVat: number;
|
|
28
39
|
currency: Currency;
|
|
40
|
+
coupon?: AppliedCoupon;
|
|
29
41
|
}
|
|
30
42
|
export interface CartCookies {
|
|
31
43
|
get(name: string): string | undefined;
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import type { CartSnapshot } from '../cart/types.js';
|
|
2
2
|
import type { OrderStatus } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Options for `createShopClient()`.
|
|
5
|
+
*
|
|
6
|
+
* @public
|
|
7
|
+
*/
|
|
3
8
|
export interface ShopClientOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Base URL for the shop REST API. Empty string (default) means same-origin —
|
|
11
|
+
* paths like `/api/shop/cart` resolve relative to the current document.
|
|
12
|
+
* Provide an absolute URL when calling from a different origin (e.g. a
|
|
13
|
+
* mobile app or static site hitting your CMS over HTTPS).
|
|
14
|
+
*/
|
|
4
15
|
baseUrl?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Custom `fetch` implementation. Defaults to `globalThis.fetch`. Override
|
|
18
|
+
* to inject auth headers, request logging, or to use `node-fetch` in tests.
|
|
19
|
+
*/
|
|
5
20
|
fetch?: typeof fetch;
|
|
6
21
|
}
|
|
7
22
|
export interface ShippingMethodPublic {
|
|
@@ -99,28 +114,131 @@ export interface RetryPaymentResult {
|
|
|
99
114
|
requiresPaymentRedirect: boolean;
|
|
100
115
|
redirectUrl: string | null;
|
|
101
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Headless shop SDK surface returned by `createShopClient()`.
|
|
119
|
+
*
|
|
120
|
+
* Every method maps 1:1 to a REST endpoint mounted by the shop HTTP handlers.
|
|
121
|
+
* The shape is stable across patch versions; new methods may be added under
|
|
122
|
+
* existing namespaces in minor releases.
|
|
123
|
+
*
|
|
124
|
+
* @public
|
|
125
|
+
*/
|
|
102
126
|
export interface ShopClient {
|
|
127
|
+
/** Cart operations — read state and mutate items / coupon. */
|
|
103
128
|
cart: {
|
|
129
|
+
/**
|
|
130
|
+
* Get the current cart snapshot, including hydrated line totals,
|
|
131
|
+
* subtotal, and any applied coupon.
|
|
132
|
+
*
|
|
133
|
+
* @returns Server-authoritative cart snapshot.
|
|
134
|
+
*/
|
|
104
135
|
get(): Promise<CartSnapshot>;
|
|
136
|
+
/**
|
|
137
|
+
* Add `qty` units of a variant to the cart. Existing entries for the
|
|
138
|
+
* same `variantId` are merged (qty summed, capped at server max).
|
|
139
|
+
*
|
|
140
|
+
* @param variantId - The product variant id.
|
|
141
|
+
* @param qty - Quantity to add. Defaults to 1.
|
|
142
|
+
*/
|
|
105
143
|
add(variantId: string, qty?: number): Promise<CartSnapshot>;
|
|
144
|
+
/**
|
|
145
|
+
* Set the absolute quantity of an existing line. `qty <= 0` removes
|
|
146
|
+
* the line.
|
|
147
|
+
*/
|
|
106
148
|
update(variantId: string, qty: number): Promise<CartSnapshot>;
|
|
149
|
+
/** Remove a single variant from the cart. */
|
|
107
150
|
remove(variantId: string): Promise<CartSnapshot>;
|
|
151
|
+
/** Empty the cart and drop any applied coupon. */
|
|
108
152
|
clear(): Promise<CartSnapshot>;
|
|
153
|
+
/**
|
|
154
|
+
* Apply a coupon code to the current cart. Codes are case-insensitive
|
|
155
|
+
* and validated server-side (existence, expiry, max-uses, min-order).
|
|
156
|
+
* Throws on invalid codes — use a try/catch and surface the message.
|
|
157
|
+
*
|
|
158
|
+
* @param code - The coupon code (e.g. `"ARIA10"`).
|
|
159
|
+
*/
|
|
160
|
+
applyCoupon(code: string): Promise<CartSnapshot>;
|
|
161
|
+
/** Remove the currently-applied coupon (if any). Always succeeds. */
|
|
162
|
+
removeCoupon(): Promise<CartSnapshot>;
|
|
109
163
|
};
|
|
164
|
+
/** Shipping method discovery. */
|
|
110
165
|
shipping: {
|
|
166
|
+
/**
|
|
167
|
+
* List all active shipping methods configured by the shop, with
|
|
168
|
+
* pricing and carrier metadata. Public — usable on the storefront.
|
|
169
|
+
*/
|
|
111
170
|
list(): Promise<{
|
|
112
171
|
items: ShippingMethodPublic[];
|
|
113
172
|
}>;
|
|
114
173
|
};
|
|
174
|
+
/** Checkout flow — converts the cart into an order. */
|
|
115
175
|
checkout: {
|
|
176
|
+
/**
|
|
177
|
+
* Submit the cart as an order. Validates consents, stock, coupons,
|
|
178
|
+
* carrier selection, and initiates the chosen payment adapter. Returns
|
|
179
|
+
* a redirect URL when the payment provider needs to take over (e.g.
|
|
180
|
+
* Stripe Checkout, PayU), or `paymentStatus: 'manual'` for manual
|
|
181
|
+
* methods (bank transfer).
|
|
182
|
+
*/
|
|
116
183
|
submit(input: CheckoutInput): Promise<CheckoutResult>;
|
|
117
184
|
};
|
|
185
|
+
/** Order lookup + payment retry/refresh from the customer side. */
|
|
118
186
|
orders: {
|
|
187
|
+
/**
|
|
188
|
+
* Fetch full order detail. Pass the customer's `accessToken` (returned
|
|
189
|
+
* from checkout) to bypass the same-browser cookie fallback.
|
|
190
|
+
*/
|
|
119
191
|
get(orderNumber: string, token?: string): Promise<OrderDetailResponse>;
|
|
192
|
+
/**
|
|
193
|
+
* Re-poll the payment provider for the latest status — useful when a
|
|
194
|
+
* webhook is lost or the customer returns from a redirect before the
|
|
195
|
+
* status webhook arrived.
|
|
196
|
+
*/
|
|
120
197
|
refreshPayment(orderNumber: string, token?: string): Promise<RefreshPaymentResult>;
|
|
198
|
+
/**
|
|
199
|
+
* Initiate a fresh payment attempt for an unpaid order — typical use
|
|
200
|
+
* is recovering from `paymentRejected` without making the customer
|
|
201
|
+
* re-fill the checkout form.
|
|
202
|
+
*/
|
|
121
203
|
retryPayment(orderNumber: string, token?: string): Promise<RetryPaymentResult>;
|
|
122
204
|
};
|
|
123
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Create a typed, isomorphic shop SDK client.
|
|
208
|
+
*
|
|
209
|
+
* The client is a thin REST wrapper — every call hits an HTTP endpoint
|
|
210
|
+
* mounted by the shop handlers (`createCartHandler`, `createCheckoutHandler`,
|
|
211
|
+
* etc.). Use it from SvelteKit pages, server endpoints, mobile apps, or any
|
|
212
|
+
* environment with a `fetch` implementation.
|
|
213
|
+
*
|
|
214
|
+
* Errors from the shop API are surfaced as `Error` with the server-provided
|
|
215
|
+
* `error` field as the message.
|
|
216
|
+
*
|
|
217
|
+
* @param options - Optional `baseUrl` (default same-origin) and custom `fetch`.
|
|
218
|
+
* @returns A `ShopClient` with `.cart`, `.shipping`, `.checkout`, `.orders`.
|
|
219
|
+
* @public
|
|
220
|
+
* @example
|
|
221
|
+
* ```ts
|
|
222
|
+
* import { createShopClient } from 'includio-cms/shop/client';
|
|
223
|
+
*
|
|
224
|
+
* const shop = createShopClient({ baseUrl: '' });
|
|
225
|
+
*
|
|
226
|
+
* await shop.cart.add(variantId);
|
|
227
|
+
* await shop.cart.applyCoupon('ARIA10');
|
|
228
|
+
* const cart = await shop.cart.get();
|
|
229
|
+
*
|
|
230
|
+
* const result = await shop.checkout.submit({
|
|
231
|
+
* customerEmail: 'buyer@example.com',
|
|
232
|
+
* shippingMethodId,
|
|
233
|
+
* paymentMethod: 'stripe',
|
|
234
|
+
* consents: [{ id: 'tos', accepted: true }]
|
|
235
|
+
* });
|
|
236
|
+
*
|
|
237
|
+
* if (result.requiresPaymentRedirect && result.redirectUrl) {
|
|
238
|
+
* window.location.href = result.redirectUrl;
|
|
239
|
+
* }
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
124
242
|
export declare function createShopClient(options?: ShopClientOptions): ShopClient;
|
|
125
243
|
export type { CartSnapshot, CartLine, CartItemRef } from '../cart/types.js';
|
|
126
244
|
export { createOrderState } from './use-order.svelte.js';
|
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a typed, isomorphic shop SDK client.
|
|
3
|
+
*
|
|
4
|
+
* The client is a thin REST wrapper — every call hits an HTTP endpoint
|
|
5
|
+
* mounted by the shop handlers (`createCartHandler`, `createCheckoutHandler`,
|
|
6
|
+
* etc.). Use it from SvelteKit pages, server endpoints, mobile apps, or any
|
|
7
|
+
* environment with a `fetch` implementation.
|
|
8
|
+
*
|
|
9
|
+
* Errors from the shop API are surfaced as `Error` with the server-provided
|
|
10
|
+
* `error` field as the message.
|
|
11
|
+
*
|
|
12
|
+
* @param options - Optional `baseUrl` (default same-origin) and custom `fetch`.
|
|
13
|
+
* @returns A `ShopClient` with `.cart`, `.shipping`, `.checkout`, `.orders`.
|
|
14
|
+
* @public
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { createShopClient } from 'includio-cms/shop/client';
|
|
18
|
+
*
|
|
19
|
+
* const shop = createShopClient({ baseUrl: '' });
|
|
20
|
+
*
|
|
21
|
+
* await shop.cart.add(variantId);
|
|
22
|
+
* await shop.cart.applyCoupon('ARIA10');
|
|
23
|
+
* const cart = await shop.cart.get();
|
|
24
|
+
*
|
|
25
|
+
* const result = await shop.checkout.submit({
|
|
26
|
+
* customerEmail: 'buyer@example.com',
|
|
27
|
+
* shippingMethodId,
|
|
28
|
+
* paymentMethod: 'stripe',
|
|
29
|
+
* consents: [{ id: 'tos', accepted: true }]
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* if (result.requiresPaymentRedirect && result.redirectUrl) {
|
|
33
|
+
* window.location.href = result.redirectUrl;
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
1
37
|
export function createShopClient(options = {}) {
|
|
2
38
|
const base = (options.baseUrl ?? '').replace(/\/$/, '');
|
|
3
39
|
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
@@ -28,7 +64,9 @@ export function createShopClient(options = {}) {
|
|
|
28
64
|
add: (variantId, qty = 1) => call('POST', '/api/shop/cart', { variantId, qty }),
|
|
29
65
|
update: (variantId, qty) => call('PATCH', '/api/shop/cart', { variantId, qty }),
|
|
30
66
|
remove: (variantId) => call('DELETE', `/api/shop/cart?variantId=${encodeURIComponent(variantId)}`),
|
|
31
|
-
clear: () => call('DELETE', '/api/shop/cart')
|
|
67
|
+
clear: () => call('DELETE', '/api/shop/cart'),
|
|
68
|
+
applyCoupon: (code) => call('POST', '/api/shop/cart/coupon', { code }),
|
|
69
|
+
removeCoupon: () => call('DELETE', '/api/shop/cart/coupon')
|
|
32
70
|
},
|
|
33
71
|
shipping: {
|
|
34
72
|
list: () => call('GET', '/api/shop/shipping-methods')
|
|
@@ -5,3 +5,11 @@ export declare function createCartHandler(): {
|
|
|
5
5
|
PATCH: RequestHandler;
|
|
6
6
|
DELETE: RequestHandler;
|
|
7
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* Cart coupon endpoints — POST applies a code, DELETE removes it.
|
|
10
|
+
* Mount at `/api/shop/cart/coupon`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createCartCouponHandler(): {
|
|
13
|
+
POST: RequestHandler;
|
|
14
|
+
DELETE: RequestHandler;
|
|
15
|
+
};
|