@odoro-cli/server-commerce 0.1.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/LICENSE +12 -0
- package/README.md +36 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +797 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (c) 2026 BouBouw. Tous droits reserves.
|
|
2
|
+
|
|
3
|
+
Ce logiciel et son code source sont la propriete exclusive de BouBouw.
|
|
4
|
+
|
|
5
|
+
Aucune autorisation n'est accordee, expressement ou implicitement, de copier,
|
|
6
|
+
modifier, fusionner, publier, distribuer, sous-licencier ou vendre tout ou
|
|
7
|
+
partie de ce logiciel, ni d'en creer des oeuvres derivees, sans accord ecrit
|
|
8
|
+
prealable de BouBouw.
|
|
9
|
+
|
|
10
|
+
CE LOGICIEL EST FOURNI "EN L'ETAT", SANS GARANTIE D'AUCUNE SORTE, EXPRESSE OU
|
|
11
|
+
IMPLICITE, Y COMPRIS SANS S'Y LIMITER LES GARANTIES DE QUALITE MARCHANDE,
|
|
12
|
+
D'ADEQUATION A UN USAGE PARTICULIER ET D'ABSENCE DE CONTREFACON.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @odoro-cli/server-commerce
|
|
2
|
+
|
|
3
|
+
The Odoro storefront contract (`/api/storefront/*`), served by an `@odoro-cli/server` app from the site's own shop database — the `shop` capability of Odoro's cloud, version 1.
|
|
4
|
+
|
|
5
|
+
The V4 storefront script and `@odoro-cli/commerce` talk to it exactly as they talk to Odoro's central storefront.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createApp } from '@odoro-cli/server'
|
|
9
|
+
import { createCommerceModule } from '@odoro-cli/server-commerce'
|
|
10
|
+
|
|
11
|
+
createApp({
|
|
12
|
+
// …
|
|
13
|
+
modules: [
|
|
14
|
+
createCommerceModule({
|
|
15
|
+
db, // the shop database: query + transaction
|
|
16
|
+
payment, // PaymentPort: opens the payment at Odoro, returns its address
|
|
17
|
+
callbackSecret: process.env.ODORO_PAYMENT_CALLBACK_SECRET ?? '',
|
|
18
|
+
}),
|
|
19
|
+
],
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What it serves
|
|
24
|
+
|
|
25
|
+
- catalogue, product page, collections, order tracking — only what is published;
|
|
26
|
+
- the cart, in an `HttpOnly` cookie set on the first add, never on a visit;
|
|
27
|
+
- checkout: stock checked against open reservations, prices of the day (changes reported), a 30-minute reservation, payment opened through the `PaymentPort`;
|
|
28
|
+
- `POST /api/storefront/payment-callback`: the order becomes paid only on a callback signed with `callbackSecret` (HMAC-SHA256 of `reference.state.timestamp`, 5-minute window). Idempotent: the stock leaves once.
|
|
29
|
+
|
|
30
|
+
## What it does not serve yet
|
|
31
|
+
|
|
32
|
+
Customer accounts (503, said by name) and discount codes (any code is refused by name). Product images are `null` until a storage adapter serves them.
|
|
33
|
+
|
|
34
|
+
## Tests
|
|
35
|
+
|
|
36
|
+
`COMMERCE_TEST_URL=postgres://user@host:port/postgres pnpm test` — each run creates a database, loads `test/fixtures/shop-1.0.0.sql`, and drops it.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as _odoro_cli_server from '@odoro-cli/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the commerce module needs from a database — and nothing more.
|
|
5
|
+
*
|
|
6
|
+
* `query` and `transaction`, the shape `@odoro-cli/cloud-connect` already has:
|
|
7
|
+
* the app wires its connection, and the tests a plain `pg` pool. The module
|
|
8
|
+
* never opens a connection itself, and never reads a connection string: that
|
|
9
|
+
* belongs to the app's configuration.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
/** Something that runs SQL: a connection, or a transaction. */
|
|
14
|
+
interface Query {
|
|
15
|
+
query<Row extends object = Record<string, unknown>>(text: string, values?: readonly unknown[]): Promise<{
|
|
16
|
+
readonly rows: readonly Row[];
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
/** A database the module can also run a transaction on. */
|
|
20
|
+
interface Base extends Query {
|
|
21
|
+
transaction<T>(work: (tx: Query) => Promise<T>): Promise<T>;
|
|
22
|
+
}
|
|
23
|
+
/** The capability version this module speaks. A `shop` 2.x would not be read. */
|
|
24
|
+
declare const SHOP_MAJOR = 1;
|
|
25
|
+
/**
|
|
26
|
+
* Is the `shop` capability installed, at a version this module reads?
|
|
27
|
+
*
|
|
28
|
+
* Read in the database's own registry (`odoro.features`), which the capability
|
|
29
|
+
* manager writes. Checked once per process: a capability is not uninstalled
|
|
30
|
+
* under a running site.
|
|
31
|
+
*/
|
|
32
|
+
declare function shopInstalled(base: Query): Promise<boolean>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The cart, found by its token.
|
|
36
|
+
*
|
|
37
|
+
* The token travels in an `HttpOnly` cookie: no script of the page reads it,
|
|
38
|
+
* so no third-party script injected into a site can copy it elsewhere. It is
|
|
39
|
+
* set on a GESTURE (an add), never on a visit — a site that sets a cookie on
|
|
40
|
+
* every page view would need a consent banner for it.
|
|
41
|
+
*
|
|
42
|
+
* The unit price is recorded at the add: checkout compares it with the price of
|
|
43
|
+
* the day, and says so when it changed.
|
|
44
|
+
*
|
|
45
|
+
* @module
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
declare const CART_COOKIE = "odoro_panier";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Checkout: the order lives in the site's database, the MONEY does not.
|
|
52
|
+
*
|
|
53
|
+
* Decided with the founder of Odoro (DECISIONS §43 of the main repository):
|
|
54
|
+
* the payment stays with Odoro (Whop), which knows money and its obligations.
|
|
55
|
+
* This module opens the order, reserves the stock, and hands the payment to a
|
|
56
|
+
* PORT the app wires — Odoro's payment API. The order only keeps the opaque
|
|
57
|
+
* reference the payment returned; it learns the outcome through a SIGNED
|
|
58
|
+
* callback (`confirmPayment`), never from the visitor's browser.
|
|
59
|
+
*
|
|
60
|
+
* The three gestures of the storefront contract:
|
|
61
|
+
*
|
|
62
|
+
* · `ouvrir` — reprices the cart at today's prices (and says what changed),
|
|
63
|
+
* checks and reserves the stock for thirty minutes, writes the order;
|
|
64
|
+
* · `chiffrer` — the total. Discount codes are not served by this module
|
|
65
|
+
* yet: a code is refused by name, never silently ignored;
|
|
66
|
+
* · `payer` — the payment page, from the port.
|
|
67
|
+
*
|
|
68
|
+
* @module
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/** What the visitor typed, under the storefront contract's names. */
|
|
72
|
+
interface CheckoutInput {
|
|
73
|
+
courriel: string;
|
|
74
|
+
nom: string;
|
|
75
|
+
ligne1: string;
|
|
76
|
+
ligne2?: string;
|
|
77
|
+
code_postal: string;
|
|
78
|
+
ville: string;
|
|
79
|
+
pays: string;
|
|
80
|
+
telephone?: string;
|
|
81
|
+
}
|
|
82
|
+
/** Where the money goes: Odoro's payment, wired by the app. */
|
|
83
|
+
interface PaymentPort {
|
|
84
|
+
open(input: {
|
|
85
|
+
readonly orderId: string;
|
|
86
|
+
readonly number: number;
|
|
87
|
+
readonly totalCents: number;
|
|
88
|
+
readonly currency: string;
|
|
89
|
+
readonly email: string;
|
|
90
|
+
}): Promise<{
|
|
91
|
+
readonly reference: string;
|
|
92
|
+
readonly paymentUrl: string;
|
|
93
|
+
}>;
|
|
94
|
+
}
|
|
95
|
+
/** The signature of a payment callback: HMAC-SHA256 of `reference.state.timestamp`. */
|
|
96
|
+
declare function signCallback(secret: string, reference: string, state: string, timestamp: number): string;
|
|
97
|
+
declare function verifyCallback(secret: string, input: {
|
|
98
|
+
reference: string;
|
|
99
|
+
etat: string;
|
|
100
|
+
horodatage: number;
|
|
101
|
+
signature: string;
|
|
102
|
+
}, now?: number): boolean;
|
|
103
|
+
|
|
104
|
+
interface CommerceOptions {
|
|
105
|
+
/** The site's shop database (`@odoro-cli/cloud-connect`, or any `Base`). */
|
|
106
|
+
readonly db: Base;
|
|
107
|
+
/** Where the money goes: Odoro's payment. */
|
|
108
|
+
readonly payment: PaymentPort;
|
|
109
|
+
/** The secret Odoro signs its payment callbacks with. Empty = callbacks refused. */
|
|
110
|
+
readonly callbackSecret: string;
|
|
111
|
+
/** The shop's currency. @defaultValue 'EUR' */
|
|
112
|
+
readonly currency?: string;
|
|
113
|
+
/** Mark the cart cookie `Secure`. @defaultValue true */
|
|
114
|
+
readonly secureCookie?: boolean;
|
|
115
|
+
}
|
|
116
|
+
declare function createCommerceModule(options: CommerceOptions): _odoro_cli_server.ModuleDefinition<Record<never, never>>;
|
|
117
|
+
|
|
118
|
+
export { type Base, CART_COOKIE, type CheckoutInput, type CommerceOptions, type PaymentPort, type Query, SHOP_MAJOR, createCommerceModule, shopInstalled, signCallback, verifyCallback };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual, randomBytes } from 'crypto';
|
|
2
|
+
import { route, defineModule, NotFoundError, ConflictError, ServiceUnavailableError, ApiError } from '@odoro-cli/server';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
// src/base.ts
|
|
6
|
+
var SHOP_MAJOR = 1;
|
|
7
|
+
async function shopInstalled(base) {
|
|
8
|
+
try {
|
|
9
|
+
const { rows } = await base.query(
|
|
10
|
+
`SELECT version FROM odoro.features WHERE name = 'shop' AND active`
|
|
11
|
+
);
|
|
12
|
+
const version = rows[0]?.version;
|
|
13
|
+
return version !== void 0 && Number.parseInt(version.split(".")[0] ?? "", 10) === SHOP_MAJOR;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/catalogue.ts
|
|
20
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
var SQL_AVAILABLE = `
|
|
22
|
+
(v.stock IS NULL OR v.stock - coalesce((
|
|
23
|
+
SELECT sum(r.quantity)::integer FROM shop.stock_reservations r
|
|
24
|
+
WHERE r.variant_id = v.id AND r.state = 'ouverte' AND r.expires_at > now()
|
|
25
|
+
), 0) >= 1)`;
|
|
26
|
+
var ORDERS = {
|
|
27
|
+
nouveautes: "p.created_at DESC, p.name",
|
|
28
|
+
prix_croissant: "p.price_cents ASC, p.name",
|
|
29
|
+
prix_decroissant: "p.price_cents DESC, p.name",
|
|
30
|
+
nom: "p.name ASC"
|
|
31
|
+
};
|
|
32
|
+
function toProduct(r) {
|
|
33
|
+
return {
|
|
34
|
+
id: r.id,
|
|
35
|
+
nom: r.name,
|
|
36
|
+
description: r.description,
|
|
37
|
+
genre: r.kind,
|
|
38
|
+
prixCentimes: Number(r.price_cents),
|
|
39
|
+
prixBarreCentimes: r.compare_at_cents === null ? null : Number(r.compare_at_cents),
|
|
40
|
+
disponible: r.available === true,
|
|
41
|
+
visuel: null,
|
|
42
|
+
etiquettes: [],
|
|
43
|
+
prixUnitaire: null
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async function readCatalogue(db, q = {}) {
|
|
47
|
+
const text2 = (q.q ?? "").trim();
|
|
48
|
+
const values = [
|
|
49
|
+
text2 === "" ? null : `%${text2.toLowerCase()}%`,
|
|
50
|
+
q.collection !== void 0 && UUID.test(q.collection) ? q.collection : null,
|
|
51
|
+
q.genre ?? null,
|
|
52
|
+
q.prixMin ?? null,
|
|
53
|
+
q.prixMax ?? null,
|
|
54
|
+
q.disponibles === true
|
|
55
|
+
];
|
|
56
|
+
const where = `
|
|
57
|
+
FROM shop.products p
|
|
58
|
+
WHERE p.published AND p.deleted_at IS NULL
|
|
59
|
+
AND ($1::text IS NULL OR lower(p.name) LIKE $1 OR lower(p.description) LIKE $1)
|
|
60
|
+
AND ($2::uuid IS NULL OR EXISTS (
|
|
61
|
+
SELECT 1 FROM shop.collection_products cp
|
|
62
|
+
JOIN shop.collections c ON c.id = cp.collection_id AND c.published
|
|
63
|
+
WHERE cp.collection_id = $2 AND cp.product_id = p.id))
|
|
64
|
+
AND ($3::text IS NULL OR p.kind = $3)
|
|
65
|
+
AND ($4::integer IS NULL OR p.price_cents >= $4)
|
|
66
|
+
AND ($5::integer IS NULL OR p.price_cents <= $5)
|
|
67
|
+
AND (NOT $6::boolean OR EXISTS (
|
|
68
|
+
SELECT 1 FROM shop.product_variants v WHERE v.product_id = p.id AND ${SQL_AVAILABLE}))`;
|
|
69
|
+
const order = (q.tri === void 0 ? void 0 : ORDERS[q.tri]) ?? "p.position, p.name";
|
|
70
|
+
const limit = Math.min(Math.max(q.combien ?? 24, 1), 60);
|
|
71
|
+
const offset = Math.max(q.depuis ?? 0, 0);
|
|
72
|
+
const { rows } = await db.query(
|
|
73
|
+
`SELECT p.id, p.name, p.description, p.kind, p.price_cents, p.compare_at_cents,
|
|
74
|
+
EXISTS (SELECT 1 FROM shop.product_variants v WHERE v.product_id = p.id AND ${SQL_AVAILABLE}) AS available
|
|
75
|
+
${where}
|
|
76
|
+
ORDER BY ${order}
|
|
77
|
+
LIMIT $7 OFFSET $8`,
|
|
78
|
+
[...values, limit, offset]
|
|
79
|
+
);
|
|
80
|
+
const { rows: count2 } = await db.query(
|
|
81
|
+
`SELECT count(*)::integer AS n ${where}`,
|
|
82
|
+
values
|
|
83
|
+
);
|
|
84
|
+
return { produits: rows.map(toProduct), total: Number(count2[0]?.n ?? 0) };
|
|
85
|
+
}
|
|
86
|
+
async function readProduct(db, id) {
|
|
87
|
+
if (!UUID.test(id)) return null;
|
|
88
|
+
const { rows } = await db.query(
|
|
89
|
+
`SELECT p.id, p.name, p.description, p.kind, p.price_cents, p.compare_at_cents,
|
|
90
|
+
EXISTS (SELECT 1 FROM shop.product_variants v WHERE v.product_id = p.id AND ${SQL_AVAILABLE}) AS available
|
|
91
|
+
FROM shop.products p
|
|
92
|
+
WHERE p.id = $1 AND p.published AND p.deleted_at IS NULL`,
|
|
93
|
+
[id]
|
|
94
|
+
);
|
|
95
|
+
const row = rows[0];
|
|
96
|
+
if (row === void 0) return null;
|
|
97
|
+
const { rows: options } = await db.query(
|
|
98
|
+
`SELECT o.id, o.name,
|
|
99
|
+
array_agg(DISTINCT vv.value) FILTER (WHERE vv.value IS NOT NULL) AS values
|
|
100
|
+
FROM shop.product_options o
|
|
101
|
+
LEFT JOIN shop.product_variant_values vv ON vv.option_id = o.id
|
|
102
|
+
WHERE o.product_id = $1
|
|
103
|
+
GROUP BY o.id, o.name, o.position
|
|
104
|
+
ORDER BY o.position, o.name`,
|
|
105
|
+
[id]
|
|
106
|
+
);
|
|
107
|
+
const { rows: variants } = await db.query(
|
|
108
|
+
`SELECT v.id,
|
|
109
|
+
coalesce(v.price_cents, p.price_cents) AS price_cents,
|
|
110
|
+
${SQL_AVAILABLE} AS available,
|
|
111
|
+
(SELECT json_agg(json_build_object('option', o.name, 'valeur', vv.value) ORDER BY o.position, o.name)
|
|
112
|
+
FROM shop.product_variant_values vv
|
|
113
|
+
JOIN shop.product_options o ON o.id = vv.option_id
|
|
114
|
+
WHERE vv.variant_id = v.id) AS choices
|
|
115
|
+
FROM shop.product_variants v
|
|
116
|
+
JOIN shop.products p ON p.id = v.product_id
|
|
117
|
+
WHERE v.product_id = $1
|
|
118
|
+
ORDER BY v.position, v.created_at`,
|
|
119
|
+
[id]
|
|
120
|
+
);
|
|
121
|
+
const product = toProduct(row);
|
|
122
|
+
const variantes = variants.map((v) => {
|
|
123
|
+
const choix = v.choices ?? [];
|
|
124
|
+
const price = Number(v.price_cents);
|
|
125
|
+
return {
|
|
126
|
+
id: v.id,
|
|
127
|
+
libelle: choix.length === 0 ? row.name : choix.map((c) => `${c.option} : ${c.valeur}`).join(" / "),
|
|
128
|
+
choix,
|
|
129
|
+
prixCentimes: price,
|
|
130
|
+
prixBarreCentimes: row.compare_at_cents !== null && row.compare_at_cents > price ? Number(row.compare_at_cents) : null,
|
|
131
|
+
disponible: v.available === true,
|
|
132
|
+
visuelRang: null,
|
|
133
|
+
prixUnitaire: null
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
...product,
|
|
138
|
+
seoTitre: row.name,
|
|
139
|
+
seoResume: row.description,
|
|
140
|
+
visuels: [],
|
|
141
|
+
options: options.map((o) => ({ id: o.id, nom: o.name, valeurs: o.values ?? [] })),
|
|
142
|
+
variantes
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async function readCollections(db) {
|
|
146
|
+
const { rows } = await db.query(
|
|
147
|
+
`SELECT c.id, c.name, c.description,
|
|
148
|
+
(SELECT count(*)::integer FROM shop.collection_products cp
|
|
149
|
+
JOIN shop.products p ON p.id = cp.product_id
|
|
150
|
+
WHERE cp.collection_id = c.id AND p.published AND p.deleted_at IS NULL) AS n
|
|
151
|
+
FROM shop.collections c
|
|
152
|
+
WHERE c.published
|
|
153
|
+
ORDER BY c.name`
|
|
154
|
+
);
|
|
155
|
+
return rows.map((c) => ({
|
|
156
|
+
id: c.id,
|
|
157
|
+
nom: c.name,
|
|
158
|
+
description: c.description,
|
|
159
|
+
combien: Number(c.n)
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/cart.ts
|
|
164
|
+
var CART_COOKIE = "odoro_panier";
|
|
165
|
+
var CART_MAX_AGE = 60 * 60 * 24 * 30;
|
|
166
|
+
var TOKEN = /^vitrine-[0-9a-f]{48}$/;
|
|
167
|
+
function isCartToken(value) {
|
|
168
|
+
return value !== void 0 && TOKEN.test(value);
|
|
169
|
+
}
|
|
170
|
+
function newCartToken() {
|
|
171
|
+
return `vitrine-${randomBytes(24).toString("hex")}`;
|
|
172
|
+
}
|
|
173
|
+
var CartError = class extends Error {
|
|
174
|
+
};
|
|
175
|
+
async function findCart(db, token) {
|
|
176
|
+
if (!isCartToken(token)) return null;
|
|
177
|
+
const { rows } = await db.query(
|
|
178
|
+
`SELECT id FROM shop.carts WHERE token = $1 AND state = 'ouvert' AND NOT preview`,
|
|
179
|
+
[token]
|
|
180
|
+
);
|
|
181
|
+
const row = rows[0];
|
|
182
|
+
return row === void 0 ? null : { id: row.id, token };
|
|
183
|
+
}
|
|
184
|
+
async function openCart(db) {
|
|
185
|
+
const token = newCartToken();
|
|
186
|
+
const { rows } = await db.query(
|
|
187
|
+
`INSERT INTO shop.carts (token) VALUES ($1) RETURNING id`,
|
|
188
|
+
[token]
|
|
189
|
+
);
|
|
190
|
+
return { id: rows[0].id, token };
|
|
191
|
+
}
|
|
192
|
+
async function touch(db, cartId) {
|
|
193
|
+
await db.query("UPDATE shop.carts SET updated_at = now() WHERE id = $1", [cartId]);
|
|
194
|
+
}
|
|
195
|
+
async function addToCart(db, cartId, variantId, quantity) {
|
|
196
|
+
const qty = Math.min(Math.max(Math.trunc(Number(quantity) || 1), 1), 99);
|
|
197
|
+
const { rows } = await db.query(
|
|
198
|
+
`INSERT INTO shop.cart_lines (cart_id, variant_id, quantity, unit_price_cents)
|
|
199
|
+
SELECT $1, v.id, $3, coalesce(v.price_cents, p.price_cents)
|
|
200
|
+
FROM shop.product_variants v
|
|
201
|
+
JOIN shop.products p ON p.id = v.product_id
|
|
202
|
+
WHERE v.id = $2 AND p.published AND p.deleted_at IS NULL
|
|
203
|
+
ON CONFLICT (cart_id, variant_id)
|
|
204
|
+
DO UPDATE SET quantity = least(shop.cart_lines.quantity + excluded.quantity, 99)
|
|
205
|
+
RETURNING variant_id`,
|
|
206
|
+
[cartId, variantId, qty]
|
|
207
|
+
);
|
|
208
|
+
if (rows.length === 0) throw new CartError("Cet article n'est plus en vente.");
|
|
209
|
+
await touch(db, cartId);
|
|
210
|
+
}
|
|
211
|
+
async function setQuantity(db, cartId, variantId, quantity) {
|
|
212
|
+
const qty = Math.min(Math.max(Math.trunc(Number(quantity) || 0), 0), 99);
|
|
213
|
+
if (qty === 0) {
|
|
214
|
+
await db.query("DELETE FROM shop.cart_lines WHERE cart_id = $1 AND variant_id = $2", [
|
|
215
|
+
cartId,
|
|
216
|
+
variantId
|
|
217
|
+
]);
|
|
218
|
+
} else {
|
|
219
|
+
await db.query(
|
|
220
|
+
"UPDATE shop.cart_lines SET quantity = $3 WHERE cart_id = $1 AND variant_id = $2",
|
|
221
|
+
[cartId, variantId, qty]
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
await touch(db, cartId);
|
|
225
|
+
}
|
|
226
|
+
async function readCart(db, cart, isNew = false) {
|
|
227
|
+
const { rows } = await db.query(
|
|
228
|
+
`SELECT l.variant_id, v.product_id, p.name,
|
|
229
|
+
(SELECT string_agg(vv.value, ' / ' ORDER BY o.position)
|
|
230
|
+
FROM shop.product_variant_values vv
|
|
231
|
+
JOIN shop.product_options o ON o.id = vv.option_id
|
|
232
|
+
WHERE vv.variant_id = v.id) AS label,
|
|
233
|
+
l.unit_price_cents, l.quantity,
|
|
234
|
+
(p.published AND p.deleted_at IS NULL AND ${SQL_AVAILABLE}) AS available
|
|
235
|
+
FROM shop.cart_lines l
|
|
236
|
+
JOIN shop.product_variants v ON v.id = l.variant_id
|
|
237
|
+
JOIN shop.products p ON p.id = v.product_id
|
|
238
|
+
WHERE l.cart_id = $1
|
|
239
|
+
ORDER BY l.added_at, l.variant_id`,
|
|
240
|
+
[cart.id]
|
|
241
|
+
);
|
|
242
|
+
const { rows: head } = await db.query(
|
|
243
|
+
"SELECT email FROM shop.carts WHERE id = $1",
|
|
244
|
+
[cart.id]
|
|
245
|
+
);
|
|
246
|
+
const lignes = rows.map((l) => ({
|
|
247
|
+
varianteId: l.variant_id,
|
|
248
|
+
produitId: l.product_id,
|
|
249
|
+
nom: l.name,
|
|
250
|
+
declinaison: l.label ?? "",
|
|
251
|
+
prixUnitaireCentimes: Number(l.unit_price_cents),
|
|
252
|
+
quantite: Number(l.quantity),
|
|
253
|
+
sousTotalCentimes: Number(l.unit_price_cents) * Number(l.quantity),
|
|
254
|
+
disponible: l.available === true,
|
|
255
|
+
visuel: null
|
|
256
|
+
}));
|
|
257
|
+
return {
|
|
258
|
+
panierId: cart.id,
|
|
259
|
+
jeton: cart.token,
|
|
260
|
+
courriel: head[0]?.email ?? null,
|
|
261
|
+
lignes,
|
|
262
|
+
combien: lignes.reduce((n, l) => n + l.quantite, 0),
|
|
263
|
+
sousTotalCentimes: lignes.reduce((n, l) => n + l.sousTotalCentimes, 0),
|
|
264
|
+
neuf: isNew
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
var CheckoutError = class extends Error {
|
|
268
|
+
constructor(status, message) {
|
|
269
|
+
super(message);
|
|
270
|
+
this.status = status;
|
|
271
|
+
}
|
|
272
|
+
status;
|
|
273
|
+
};
|
|
274
|
+
var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
275
|
+
var RESERVATION_MINUTES = 30;
|
|
276
|
+
function need(value, message) {
|
|
277
|
+
const v = (value ?? "").trim();
|
|
278
|
+
if (v === "") throw new CheckoutError(400, message);
|
|
279
|
+
return v;
|
|
280
|
+
}
|
|
281
|
+
async function quote(db, orderId) {
|
|
282
|
+
const { rows } = await db.query("SELECT * FROM shop.orders WHERE id = $1", [
|
|
283
|
+
orderId
|
|
284
|
+
]);
|
|
285
|
+
const order = rows[0];
|
|
286
|
+
if (order === void 0) throw new CheckoutError(404, "Cette caisse n'existe pas.");
|
|
287
|
+
const { rows: lines } = await db.query(
|
|
288
|
+
`SELECT l.variant_id, v.product_id, l.product_name, l.unit_price_cents, l.quantity
|
|
289
|
+
FROM shop.order_lines l LEFT JOIN shop.product_variants v ON v.id = l.variant_id
|
|
290
|
+
WHERE l.order_id = $1 ORDER BY l.product_name`,
|
|
291
|
+
[orderId]
|
|
292
|
+
);
|
|
293
|
+
return {
|
|
294
|
+
caisseId: order.id,
|
|
295
|
+
devise: order.currency,
|
|
296
|
+
lignes: lines.map((l) => ({
|
|
297
|
+
varianteId: l.variant_id ?? "",
|
|
298
|
+
produitId: l.product_id ?? "",
|
|
299
|
+
nom: l.product_name,
|
|
300
|
+
genre: "physique",
|
|
301
|
+
prixUnitaireCentimes: Number(l.unit_price_cents),
|
|
302
|
+
quantite: Number(l.quantity),
|
|
303
|
+
sousTotalCentimes: Number(l.unit_price_cents) * Number(l.quantity),
|
|
304
|
+
taxable: true
|
|
305
|
+
})),
|
|
306
|
+
remises: [],
|
|
307
|
+
carteCadeau: null,
|
|
308
|
+
methodeDeLivraison: null,
|
|
309
|
+
sousTotalCentimes: Number(order.subtotal_cents),
|
|
310
|
+
remiseCentimes: 0,
|
|
311
|
+
portCentimes: Number(order.shipping_cents),
|
|
312
|
+
taxeCentimes: Number(order.tax_cents),
|
|
313
|
+
carteCadeauCentimes: 0,
|
|
314
|
+
totalCentimes: Number(order.total_cents),
|
|
315
|
+
taxeIncluse: true
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
async function openCheckout(db, cartId, input, currency = "EUR") {
|
|
319
|
+
const email = need(input.courriel, "Indiquez votre adresse e-mail.");
|
|
320
|
+
if (!EMAIL.test(email))
|
|
321
|
+
throw new CheckoutError(400, "Cette adresse e-mail n'est pas valable.");
|
|
322
|
+
const name = need(input.nom, "Indiquez votre nom.");
|
|
323
|
+
const address = {
|
|
324
|
+
ligne1: need(input.ligne1, "Indiquez votre adresse."),
|
|
325
|
+
ligne2: (input.ligne2 ?? "").trim(),
|
|
326
|
+
code_postal: need(input.code_postal, "Indiquez votre code postal."),
|
|
327
|
+
ville: need(input.ville, "Indiquez votre ville."),
|
|
328
|
+
pays: need(input.pays, "Indiquez votre pays."),
|
|
329
|
+
telephone: (input.telephone ?? "").trim()
|
|
330
|
+
};
|
|
331
|
+
return await db.transaction(async (tx) => {
|
|
332
|
+
await tx.query(
|
|
333
|
+
`UPDATE shop.stock_reservations SET state = 'relachee'
|
|
334
|
+
WHERE cart_id = $1 AND state = 'ouverte'`,
|
|
335
|
+
[cartId]
|
|
336
|
+
);
|
|
337
|
+
await tx.query(
|
|
338
|
+
`UPDATE shop.orders SET state = 'annulee'
|
|
339
|
+
WHERE cart_id = $1 AND state = 'ouverte' AND payment_ref IS NULL`,
|
|
340
|
+
[cartId]
|
|
341
|
+
);
|
|
342
|
+
const { rows: lines } = await tx.query(
|
|
343
|
+
`SELECT l.variant_id, l.quantity, l.unit_price_cents,
|
|
344
|
+
coalesce(v.price_cents, p.price_cents) AS current_cents,
|
|
345
|
+
v.stock, (p.published AND p.deleted_at IS NULL) AS for_sale, p.name,
|
|
346
|
+
(SELECT string_agg(vv.value, ' / ' ORDER BY o.position)
|
|
347
|
+
FROM shop.product_variant_values vv JOIN shop.product_options o ON o.id = vv.option_id
|
|
348
|
+
WHERE vv.variant_id = v.id) AS label
|
|
349
|
+
FROM shop.cart_lines l
|
|
350
|
+
JOIN shop.product_variants v ON v.id = l.variant_id
|
|
351
|
+
JOIN shop.products p ON p.id = v.product_id
|
|
352
|
+
WHERE l.cart_id = $1
|
|
353
|
+
ORDER BY l.variant_id
|
|
354
|
+
FOR UPDATE OF v`,
|
|
355
|
+
[cartId]
|
|
356
|
+
);
|
|
357
|
+
if (lines.length === 0) throw new CartError("Votre panier est vide.");
|
|
358
|
+
const gone = lines.find((l) => !l.for_sale);
|
|
359
|
+
if (gone !== void 0) {
|
|
360
|
+
throw new CheckoutError(
|
|
361
|
+
409,
|
|
362
|
+
`\xAB ${gone.name} \xBB n'est plus en vente : retirez-le de votre panier.`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
for (const l of lines) {
|
|
366
|
+
if (l.stock === null) continue;
|
|
367
|
+
const { rows } = await tx.query(
|
|
368
|
+
`SELECT coalesce(sum(quantity), 0)::integer AS held FROM shop.stock_reservations
|
|
369
|
+
WHERE variant_id = $1 AND state = 'ouverte' AND expires_at > now()`,
|
|
370
|
+
[l.variant_id]
|
|
371
|
+
);
|
|
372
|
+
if (Number(l.stock) - Number(rows[0]?.held ?? 0) < Number(l.quantity)) {
|
|
373
|
+
throw new CheckoutError(409, `\xAB ${l.name} \xBB : il n'en reste pas assez.`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const repriced = lines.filter((l) => Number(l.current_cents) !== Number(l.unit_price_cents)).map((l) => ({
|
|
377
|
+
varianteId: l.variant_id,
|
|
378
|
+
ancienCentimes: Number(l.unit_price_cents),
|
|
379
|
+
nouveauCentimes: Number(l.current_cents)
|
|
380
|
+
}));
|
|
381
|
+
for (const r of repriced) {
|
|
382
|
+
await tx.query(
|
|
383
|
+
"UPDATE shop.cart_lines SET unit_price_cents = $3 WHERE cart_id = $1 AND variant_id = $2",
|
|
384
|
+
[cartId, r.varianteId, r.nouveauCentimes]
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
const subtotal = lines.reduce(
|
|
388
|
+
(n, l) => n + Number(l.current_cents) * Number(l.quantity),
|
|
389
|
+
0
|
|
390
|
+
);
|
|
391
|
+
const { rows: created } = await tx.query(
|
|
392
|
+
`INSERT INTO shop.orders (cart_id, email, customer_name, shipping_address, currency, subtotal_cents, total_cents)
|
|
393
|
+
VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING id`,
|
|
394
|
+
[cartId, email, name, JSON.stringify(address), currency, subtotal]
|
|
395
|
+
);
|
|
396
|
+
const orderId = created[0].id;
|
|
397
|
+
for (const l of lines) {
|
|
398
|
+
await tx.query(
|
|
399
|
+
`INSERT INTO shop.order_lines (order_id, variant_id, product_name, variant_label, unit_price_cents, quantity)
|
|
400
|
+
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
401
|
+
[orderId, l.variant_id, l.name, l.label ?? "", l.current_cents, l.quantity]
|
|
402
|
+
);
|
|
403
|
+
if (l.stock !== null) {
|
|
404
|
+
await tx.query(
|
|
405
|
+
`INSERT INTO shop.stock_reservations (variant_id, cart_id, quantity, expires_at)
|
|
406
|
+
VALUES ($1, $2, $3, now() + make_interval(mins => $4))`,
|
|
407
|
+
[l.variant_id, cartId, l.quantity, RESERVATION_MINUTES]
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
await tx.query("UPDATE shop.carts SET email = $2, updated_at = now() WHERE id = $1", [
|
|
412
|
+
cartId,
|
|
413
|
+
email
|
|
414
|
+
]);
|
|
415
|
+
return { orderId, repriced };
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
async function applyCode(db, orderId, code) {
|
|
419
|
+
if ((code ?? "").trim() !== "") {
|
|
420
|
+
throw new CheckoutError(409, "Ce code n'est pas reconnu par cette boutique.");
|
|
421
|
+
}
|
|
422
|
+
return await quote(db, orderId);
|
|
423
|
+
}
|
|
424
|
+
async function pay(db, orderId, port) {
|
|
425
|
+
const { rows } = await db.query(
|
|
426
|
+
`SELECT o.*,
|
|
427
|
+
EXISTS (SELECT 1 FROM shop.stock_reservations r
|
|
428
|
+
WHERE r.cart_id = o.cart_id AND r.state = 'ouverte' AND r.expires_at <= now()) AS expired
|
|
429
|
+
FROM shop.orders o WHERE o.id = $1`,
|
|
430
|
+
[orderId]
|
|
431
|
+
);
|
|
432
|
+
const order = rows[0];
|
|
433
|
+
if (order === void 0) throw new CheckoutError(404, "Cette caisse n'existe pas.");
|
|
434
|
+
if (order.state !== "ouverte")
|
|
435
|
+
throw new CheckoutError(409, "Cette commande ne peut plus \xEAtre pay\xE9e.");
|
|
436
|
+
if (order.expired)
|
|
437
|
+
throw new CheckoutError(409, "Votre r\xE9servation a expir\xE9 : rouvrez la caisse.");
|
|
438
|
+
const opened = await port.open({
|
|
439
|
+
orderId: order.id,
|
|
440
|
+
number: Number(order.number),
|
|
441
|
+
totalCents: Number(order.total_cents),
|
|
442
|
+
currency: order.currency,
|
|
443
|
+
email: order.email
|
|
444
|
+
});
|
|
445
|
+
await db.query("UPDATE shop.orders SET payment_ref = $2 WHERE id = $1", [
|
|
446
|
+
order.id,
|
|
447
|
+
opened.reference
|
|
448
|
+
]);
|
|
449
|
+
const total = Number(order.total_cents);
|
|
450
|
+
return {
|
|
451
|
+
commandeId: order.id,
|
|
452
|
+
numero: Number(order.number),
|
|
453
|
+
jetonDeSuivi: order.id,
|
|
454
|
+
totalCentimes: total,
|
|
455
|
+
totalTexte: new Intl.NumberFormat("fr", {
|
|
456
|
+
style: "currency",
|
|
457
|
+
currency: order.currency
|
|
458
|
+
}).format(total / 100),
|
|
459
|
+
adresseDePaiement: opened.paymentUrl
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
var CALLBACK_WINDOW_S = 300;
|
|
463
|
+
function signCallback(secret, reference, state, timestamp) {
|
|
464
|
+
return createHmac("sha256", secret).update(`${reference}.${state}.${timestamp}`).digest("hex");
|
|
465
|
+
}
|
|
466
|
+
function verifyCallback(secret, input, now = Date.now()) {
|
|
467
|
+
if (secret === "" || Math.abs(now / 1e3 - input.horodatage) > CALLBACK_WINDOW_S)
|
|
468
|
+
return false;
|
|
469
|
+
const expected = Buffer.from(
|
|
470
|
+
signCallback(secret, input.reference, input.etat, input.horodatage)
|
|
471
|
+
);
|
|
472
|
+
const received = Buffer.from(input.signature);
|
|
473
|
+
return expected.length === received.length && timingSafeEqual(expected, received);
|
|
474
|
+
}
|
|
475
|
+
async function confirmPayment(db, reference, state) {
|
|
476
|
+
return await db.transaction(async (tx) => {
|
|
477
|
+
const { rows } = await tx.query("SELECT id, cart_id, state FROM shop.orders WHERE payment_ref = $1 FOR UPDATE", [
|
|
478
|
+
reference
|
|
479
|
+
]);
|
|
480
|
+
const order = rows[0];
|
|
481
|
+
if (order === void 0) return false;
|
|
482
|
+
if (order.state !== "ouverte") return true;
|
|
483
|
+
if (state === "echouee") {
|
|
484
|
+
await tx.query(`UPDATE shop.orders SET state = 'echouee' WHERE id = $1`, [order.id]);
|
|
485
|
+
await tx.query(
|
|
486
|
+
`UPDATE shop.stock_reservations SET state = 'relachee' WHERE cart_id = $1 AND state = 'ouverte'`,
|
|
487
|
+
[order.cart_id]
|
|
488
|
+
);
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
await tx.query(
|
|
492
|
+
`UPDATE shop.orders SET state = 'payee', paid_at = now() WHERE id = $1`,
|
|
493
|
+
[order.id]
|
|
494
|
+
);
|
|
495
|
+
await tx.query(
|
|
496
|
+
`UPDATE shop.product_variants v
|
|
497
|
+
SET stock = greatest(v.stock - l.quantity, 0)
|
|
498
|
+
FROM shop.order_lines l
|
|
499
|
+
WHERE l.order_id = $1 AND l.variant_id = v.id AND v.stock IS NOT NULL`,
|
|
500
|
+
[order.id]
|
|
501
|
+
);
|
|
502
|
+
await tx.query(
|
|
503
|
+
`UPDATE shop.stock_reservations SET state = 'consommee' WHERE cart_id = $1 AND state = 'ouverte'`,
|
|
504
|
+
[order.cart_id]
|
|
505
|
+
);
|
|
506
|
+
await tx.query(
|
|
507
|
+
`UPDATE shop.carts SET state = 'commande', updated_at = now() WHERE id = $1`,
|
|
508
|
+
[order.cart_id]
|
|
509
|
+
);
|
|
510
|
+
return true;
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
async function orderState(db, orderId) {
|
|
514
|
+
if (!/^[0-9a-f-]{36}$/i.test(orderId)) return null;
|
|
515
|
+
const { rows } = await db.query(
|
|
516
|
+
"SELECT * FROM shop.orders WHERE id = $1",
|
|
517
|
+
[orderId]
|
|
518
|
+
);
|
|
519
|
+
const order = rows[0];
|
|
520
|
+
if (order === void 0) return null;
|
|
521
|
+
const { rows: lines } = await db.query(
|
|
522
|
+
"SELECT product_name, quantity, unit_price_cents FROM shop.order_lines WHERE order_id = $1 ORDER BY product_name",
|
|
523
|
+
[orderId]
|
|
524
|
+
);
|
|
525
|
+
return {
|
|
526
|
+
numero: Number(order.number),
|
|
527
|
+
etat: order.state,
|
|
528
|
+
courriel: order.email,
|
|
529
|
+
totalCentimes: Number(order.total_cents),
|
|
530
|
+
passeeLe: new Date(order.created_at).toISOString(),
|
|
531
|
+
lignes: lines.map((l) => ({
|
|
532
|
+
nom: l.product_name,
|
|
533
|
+
quantite: Number(l.quantity),
|
|
534
|
+
sousTotalCentimes: Number(l.unit_price_cents) * Number(l.quantity)
|
|
535
|
+
})),
|
|
536
|
+
livraisons: []
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function refusal(kind, message) {
|
|
540
|
+
const extensions = { extensions: { erreur: message } };
|
|
541
|
+
if (kind === "NOT_FOUND") return new NotFoundError(message, extensions);
|
|
542
|
+
if (kind === "CONFLICT") return new ConflictError(message, extensions);
|
|
543
|
+
if (kind === "UNAVAILABLE")
|
|
544
|
+
return new ServiceUnavailableError(message, void 0, extensions);
|
|
545
|
+
return new ApiError("VALIDATION", message, extensions);
|
|
546
|
+
}
|
|
547
|
+
async function speaking(work) {
|
|
548
|
+
try {
|
|
549
|
+
return await work();
|
|
550
|
+
} catch (cause) {
|
|
551
|
+
if (cause instanceof CheckoutError) {
|
|
552
|
+
throw refusal(
|
|
553
|
+
cause.status === 404 ? "NOT_FOUND" : cause.status === 409 ? "CONFLICT" : "VALIDATION",
|
|
554
|
+
cause.message
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
if (cause instanceof CartError) throw refusal("CONFLICT", cause.message);
|
|
558
|
+
throw cause;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
var text = z.string().max(200).optional();
|
|
562
|
+
var count = z.coerce.number().int().min(0).max(1e5).optional();
|
|
563
|
+
function createCommerceModule(options) {
|
|
564
|
+
const { db } = options;
|
|
565
|
+
const currency = options.currency ?? "EUR";
|
|
566
|
+
let installed = null;
|
|
567
|
+
async function ready() {
|
|
568
|
+
installed ??= shopInstalled(db);
|
|
569
|
+
if (!await installed) {
|
|
570
|
+
installed = null;
|
|
571
|
+
throw refusal("UNAVAILABLE", "La boutique de ce site n'est pas encore install\xE9e.");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async function cartOf(cookies) {
|
|
575
|
+
return await findCart(db, cookies.get(CART_COOKIE));
|
|
576
|
+
}
|
|
577
|
+
function keep(cookies, token) {
|
|
578
|
+
cookies.set(CART_COOKIE, token, {
|
|
579
|
+
httpOnly: true,
|
|
580
|
+
sameSite: "lax",
|
|
581
|
+
maxAge: CART_MAX_AGE,
|
|
582
|
+
...options.secureCookie === false ? { secure: false } : {}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const storefront = route({
|
|
586
|
+
name: "storefront.read",
|
|
587
|
+
method: "GET",
|
|
588
|
+
path: "/api/storefront",
|
|
589
|
+
auth: "public",
|
|
590
|
+
input: z.object({
|
|
591
|
+
geste: z.enum(["catalogue", "fiche", "collections", "commande", "suggestions"]).default("catalogue"),
|
|
592
|
+
produit: text,
|
|
593
|
+
jeton: text,
|
|
594
|
+
q: text,
|
|
595
|
+
collection: text,
|
|
596
|
+
genre: text,
|
|
597
|
+
prix_min: count,
|
|
598
|
+
prix_max: count,
|
|
599
|
+
disponibles: z.string().optional(),
|
|
600
|
+
tri: z.enum(["nouveautes", "prix_croissant", "prix_decroissant", "nom"]).optional().catch(void 0),
|
|
601
|
+
combien: count,
|
|
602
|
+
depuis: count
|
|
603
|
+
}),
|
|
604
|
+
summary: "The catalogue, a product page, the collections or an order, per the storefront contract.",
|
|
605
|
+
handler: async ({ input }) => {
|
|
606
|
+
await ready();
|
|
607
|
+
if (input.geste === "fiche") {
|
|
608
|
+
const fiche = await readProduct(db, input.produit ?? "");
|
|
609
|
+
if (fiche === null) throw refusal("NOT_FOUND", "Cet article n'est plus en vente.");
|
|
610
|
+
return { fiche };
|
|
611
|
+
}
|
|
612
|
+
if (input.geste === "collections") return { collections: await readCollections(db) };
|
|
613
|
+
if (input.geste === "commande") {
|
|
614
|
+
const commande = await orderState(db, input.jeton ?? "");
|
|
615
|
+
if (commande === null) throw refusal("NOT_FOUND", "Cette commande n'existe pas.");
|
|
616
|
+
return { commande };
|
|
617
|
+
}
|
|
618
|
+
if (input.geste === "suggestions") {
|
|
619
|
+
const { produits } = await readCatalogue(db, { q: input.q ?? "", combien: 6 });
|
|
620
|
+
return { suggestions: produits.map((p) => ({ id: p.id, nom: p.nom })) };
|
|
621
|
+
}
|
|
622
|
+
return await readCatalogue(db, {
|
|
623
|
+
...input.q === void 0 ? {} : { q: input.q },
|
|
624
|
+
...input.collection === void 0 ? {} : { collection: input.collection },
|
|
625
|
+
...input.genre === void 0 ? {} : { genre: input.genre },
|
|
626
|
+
...input.prix_min === void 0 ? {} : { prixMin: input.prix_min },
|
|
627
|
+
...input.prix_max === void 0 ? {} : { prixMax: input.prix_max },
|
|
628
|
+
disponibles: input.disponibles === "1",
|
|
629
|
+
...input.tri === void 0 ? {} : { tri: input.tri },
|
|
630
|
+
...input.combien === void 0 ? {} : { combien: input.combien },
|
|
631
|
+
...input.depuis === void 0 ? {} : { depuis: input.depuis }
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
const visit = route({
|
|
636
|
+
name: "storefront.visit",
|
|
637
|
+
method: "POST",
|
|
638
|
+
path: "/api/storefront",
|
|
639
|
+
auth: "public",
|
|
640
|
+
handler: () => ({ ok: true })
|
|
641
|
+
});
|
|
642
|
+
const readCartRoute = route({
|
|
643
|
+
name: "storefront.cart.read",
|
|
644
|
+
method: "GET",
|
|
645
|
+
path: "/api/storefront/cart",
|
|
646
|
+
auth: "public",
|
|
647
|
+
handler: async ({ cookies }) => {
|
|
648
|
+
await ready();
|
|
649
|
+
const cart = await cartOf(cookies);
|
|
650
|
+
if (cart === null) {
|
|
651
|
+
return {
|
|
652
|
+
panier: {
|
|
653
|
+
panierId: "",
|
|
654
|
+
jeton: "",
|
|
655
|
+
courriel: null,
|
|
656
|
+
lignes: [],
|
|
657
|
+
combien: 0,
|
|
658
|
+
sousTotalCentimes: 0,
|
|
659
|
+
neuf: false
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
return { panier: await readCart(db, cart) };
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
const changeCart = route({
|
|
667
|
+
name: "storefront.cart.change",
|
|
668
|
+
method: "POST",
|
|
669
|
+
path: "/api/storefront/cart",
|
|
670
|
+
auth: "public",
|
|
671
|
+
input: z.object({
|
|
672
|
+
geste: z.enum(["ajouter", "quantite"]).default("ajouter"),
|
|
673
|
+
variante: z.string().regex(/^[0-9a-f-]{36}$/i, "Cet article n'est plus en vente."),
|
|
674
|
+
quantite: z.coerce.number().int().min(0).max(99).default(1)
|
|
675
|
+
}),
|
|
676
|
+
handler: async ({ input, cookies }) => await speaking(async () => {
|
|
677
|
+
await ready();
|
|
678
|
+
const existing = await cartOf(cookies);
|
|
679
|
+
const cart = existing ?? await openCart(db);
|
|
680
|
+
if (input.geste === "quantite")
|
|
681
|
+
await setQuantity(db, cart.id, input.variante, input.quantite);
|
|
682
|
+
else await addToCart(db, cart.id, input.variante, input.quantite);
|
|
683
|
+
if (existing === null) keep(cookies, cart.token);
|
|
684
|
+
return { panier: await readCart(db, cart, existing === null) };
|
|
685
|
+
})
|
|
686
|
+
});
|
|
687
|
+
const checkout = route({
|
|
688
|
+
name: "storefront.checkout",
|
|
689
|
+
method: "POST",
|
|
690
|
+
path: "/api/storefront/checkout",
|
|
691
|
+
auth: "public",
|
|
692
|
+
input: z.object({
|
|
693
|
+
geste: z.enum(["ouvrir", "chiffrer", "payer"]),
|
|
694
|
+
caisse: z.string().max(64).optional(),
|
|
695
|
+
code: z.string().max(40).optional(),
|
|
696
|
+
courriel: text,
|
|
697
|
+
nom: text,
|
|
698
|
+
ligne1: text,
|
|
699
|
+
ligne2: text,
|
|
700
|
+
code_postal: z.string().max(20).optional(),
|
|
701
|
+
ville: text,
|
|
702
|
+
pays: z.string().max(40).optional(),
|
|
703
|
+
telephone: z.string().max(30).optional()
|
|
704
|
+
}),
|
|
705
|
+
handler: async ({ input, cookies }) => await speaking(async () => {
|
|
706
|
+
await ready();
|
|
707
|
+
if (input.geste === "ouvrir") {
|
|
708
|
+
const cart = await cartOf(cookies);
|
|
709
|
+
if (cart === null) throw new CheckoutError(400, "Votre panier est vide.");
|
|
710
|
+
const opened = await openCheckout(
|
|
711
|
+
db,
|
|
712
|
+
cart.id,
|
|
713
|
+
{
|
|
714
|
+
courriel: input.courriel ?? "",
|
|
715
|
+
nom: input.nom ?? "",
|
|
716
|
+
ligne1: input.ligne1 ?? "",
|
|
717
|
+
ligne2: input.ligne2 ?? "",
|
|
718
|
+
code_postal: input.code_postal ?? "",
|
|
719
|
+
ville: input.ville ?? "",
|
|
720
|
+
pays: input.pays ?? "",
|
|
721
|
+
telephone: input.telephone ?? ""
|
|
722
|
+
},
|
|
723
|
+
currency
|
|
724
|
+
);
|
|
725
|
+
return {
|
|
726
|
+
caisse: opened.orderId,
|
|
727
|
+
prixRevalorises: opened.repriced,
|
|
728
|
+
total: await quote(db, opened.orderId)
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
const orderId = input.caisse ?? "";
|
|
732
|
+
if (!/^[0-9a-f-]{36}$/i.test(orderId))
|
|
733
|
+
throw new CheckoutError(404, "Cette caisse n'existe pas.");
|
|
734
|
+
if (input.geste === "chiffrer")
|
|
735
|
+
return { total: await applyCode(db, orderId, input.code) };
|
|
736
|
+
await applyCode(db, orderId, input.code);
|
|
737
|
+
return await pay(db, orderId, options.payment);
|
|
738
|
+
})
|
|
739
|
+
});
|
|
740
|
+
const callback = route({
|
|
741
|
+
name: "storefront.payment.callback",
|
|
742
|
+
method: "POST",
|
|
743
|
+
path: "/api/storefront/payment-callback",
|
|
744
|
+
auth: "public",
|
|
745
|
+
input: z.object({
|
|
746
|
+
reference: z.string().min(1).max(200),
|
|
747
|
+
etat: z.enum(["payee", "echouee"]),
|
|
748
|
+
horodatage: z.coerce.number().int(),
|
|
749
|
+
signature: z.string().min(1).max(128)
|
|
750
|
+
}),
|
|
751
|
+
handler: async ({ input }) => {
|
|
752
|
+
await ready();
|
|
753
|
+
if (!verifyCallback(options.callbackSecret, input)) {
|
|
754
|
+
throw refusal("NOT_FOUND", "Rappel de paiement refus\xE9.");
|
|
755
|
+
}
|
|
756
|
+
const known = await confirmPayment(db, input.reference, input.etat);
|
|
757
|
+
if (!known) throw refusal("NOT_FOUND", "Paiement inconnu.");
|
|
758
|
+
return { ok: true };
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
const accountRead = route({
|
|
762
|
+
name: "storefront.account.read",
|
|
763
|
+
method: "GET",
|
|
764
|
+
path: "/api/storefront/account",
|
|
765
|
+
auth: "public",
|
|
766
|
+
handler: () => ({ connecte: false })
|
|
767
|
+
});
|
|
768
|
+
const accountWrite = route({
|
|
769
|
+
name: "storefront.account.write",
|
|
770
|
+
method: "POST",
|
|
771
|
+
path: "/api/storefront/account",
|
|
772
|
+
auth: "public",
|
|
773
|
+
handler: () => {
|
|
774
|
+
throw refusal(
|
|
775
|
+
"UNAVAILABLE",
|
|
776
|
+
"Le compte client n'est pas encore disponible sur cette boutique."
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
return defineModule({
|
|
781
|
+
name: "commerce",
|
|
782
|
+
routes: [
|
|
783
|
+
storefront,
|
|
784
|
+
visit,
|
|
785
|
+
readCartRoute,
|
|
786
|
+
changeCart,
|
|
787
|
+
checkout,
|
|
788
|
+
callback,
|
|
789
|
+
accountRead,
|
|
790
|
+
accountWrite
|
|
791
|
+
]
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export { CART_COOKIE, SHOP_MAJOR, createCommerceModule, shopInstalled, signCallback, verifyCallback };
|
|
796
|
+
//# sourceMappingURL=index.js.map
|
|
797
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/base.ts","../src/catalogue.ts","../src/cart.ts","../src/checkout.ts","../src/module.ts"],"names":["text","count"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * What the commerce module needs from a database — and nothing more.\n *\n * `query` and `transaction`, the shape `@odoro-cli/cloud-connect` already has:\n * the app wires its connection, and the tests a plain `pg` pool. The module\n * never opens a connection itself, and never reads a connection string: that\n * belongs to the app's configuration.\n *\n * @module\n */\n\n/** Something that runs SQL: a connection, or a transaction. */\nexport interface Query {\n query<Row extends object = Record<string, unknown>>(\n text: string,\n values?: readonly unknown[],\n ): Promise<{ readonly rows: readonly Row[] }>\n}\n\n/** A database the module can also run a transaction on. */\nexport interface Base extends Query {\n transaction<T>(work: (tx: Query) => Promise<T>): Promise<T>\n}\n\n/** The capability version this module speaks. A `shop` 2.x would not be read. */\nexport const SHOP_MAJOR = 1\n\n/**\n * Is the `shop` capability installed, at a version this module reads?\n *\n * Read in the database's own registry (`odoro.features`), which the capability\n * manager writes. Checked once per process: a capability is not uninstalled\n * under a running site.\n */\nexport async function shopInstalled(base: Query): Promise<boolean> {\n try {\n const { rows } = await base.query<{ version: string }>(\n `SELECT version FROM odoro.features WHERE name = 'shop' AND active`,\n )\n const version = rows[0]?.version\n return (\n version !== undefined &&\n Number.parseInt(version.split('.')[0] ?? '', 10) === SHOP_MAJOR\n )\n } catch {\n return false\n }\n}\n","/**\n * The catalogue, read from the `shop` schema in the storefront's shapes.\n *\n * Only what is FOR SALE: published, not deleted. A draft is the merchant's\n * business; a visitor who guesses its id gets the same 404 as for a product\n * that never existed.\n *\n * Availability is per VARIANT — the stock lives there — minus the open,\n * unexpired reservations of carts being paid. An expired reservation holds\n * nothing: an abandoned checkout must not take an item off sale forever.\n *\n * Images: the capability keeps an opaque storage key, not the file. Until a\n * storage adapter serves them, `visuel` is `null` — the site shows the image it\n * was built with, as it does for a product without a photo.\n *\n * @module\n */\n\nimport type {\n CatalogueQuery,\n CollectionEnVitrine,\n FicheProduit,\n ProduitEnVitrine,\n VarianteEnVitrine,\n} from '@odoro-cli/commerce'\n\nimport type { Query } from './base.js'\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/** A variant is available when its stock, minus what is being paid, covers one. */\nexport const SQL_AVAILABLE = `\n (v.stock IS NULL OR v.stock - coalesce((\n SELECT sum(r.quantity)::integer FROM shop.stock_reservations r\n WHERE r.variant_id = v.id AND r.state = 'ouverte' AND r.expires_at > now()\n ), 0) >= 1)`\n\n/** The sort orders a visitor may ask for: a closed list, never a concatenated `ORDER BY`. */\nconst ORDERS: Readonly<Record<NonNullable<CatalogueQuery['tri']>, string>> = {\n nouveautes: 'p.created_at DESC, p.name',\n prix_croissant: 'p.price_cents ASC, p.name',\n prix_decroissant: 'p.price_cents DESC, p.name',\n nom: 'p.name ASC',\n}\n\ninterface ProductRow {\n id: string\n name: string\n description: string\n kind: string\n price_cents: number\n compare_at_cents: number | null\n available: boolean\n}\n\nfunction toProduct(r: ProductRow): ProduitEnVitrine {\n return {\n id: r.id,\n nom: r.name,\n description: r.description,\n genre: r.kind,\n prixCentimes: Number(r.price_cents),\n prixBarreCentimes: r.compare_at_cents === null ? null : Number(r.compare_at_cents),\n disponible: r.available === true,\n visuel: null,\n etiquettes: [],\n prixUnitaire: null,\n }\n}\n\nexport async function readCatalogue(\n db: Query,\n q: CatalogueQuery = {},\n): Promise<{ produits: ProduitEnVitrine[]; total: number }> {\n const text = (q.q ?? '').trim()\n const values: unknown[] = [\n text === '' ? null : `%${text.toLowerCase()}%`,\n q.collection !== undefined && UUID.test(q.collection) ? q.collection : null,\n q.genre ?? null,\n q.prixMin ?? null,\n q.prixMax ?? null,\n q.disponibles === true,\n ]\n const where = `\n FROM shop.products p\n WHERE p.published AND p.deleted_at IS NULL\n AND ($1::text IS NULL OR lower(p.name) LIKE $1 OR lower(p.description) LIKE $1)\n AND ($2::uuid IS NULL OR EXISTS (\n SELECT 1 FROM shop.collection_products cp\n JOIN shop.collections c ON c.id = cp.collection_id AND c.published\n WHERE cp.collection_id = $2 AND cp.product_id = p.id))\n AND ($3::text IS NULL OR p.kind = $3)\n AND ($4::integer IS NULL OR p.price_cents >= $4)\n AND ($5::integer IS NULL OR p.price_cents <= $5)\n AND (NOT $6::boolean OR EXISTS (\n SELECT 1 FROM shop.product_variants v WHERE v.product_id = p.id AND ${SQL_AVAILABLE}))`\n // An unknown order falls back to the merchant's, silently: it is a sort, not an error.\n const order = (q.tri === undefined ? undefined : ORDERS[q.tri]) ?? 'p.position, p.name'\n const limit = Math.min(Math.max(q.combien ?? 24, 1), 60)\n const offset = Math.max(q.depuis ?? 0, 0)\n\n const { rows } = await db.query<ProductRow>(\n `SELECT p.id, p.name, p.description, p.kind, p.price_cents, p.compare_at_cents,\n EXISTS (SELECT 1 FROM shop.product_variants v WHERE v.product_id = p.id AND ${SQL_AVAILABLE}) AS available\n ${where}\n ORDER BY ${order}\n LIMIT $7 OFFSET $8`,\n [...values, limit, offset],\n )\n const { rows: count } = await db.query<{ n: number }>(\n `SELECT count(*)::integer AS n ${where}`,\n values,\n )\n return { produits: rows.map(toProduct), total: Number(count[0]?.n ?? 0) }\n}\n\nexport async function readProduct(db: Query, id: string): Promise<FicheProduit | null> {\n if (!UUID.test(id)) return null\n const { rows } = await db.query<ProductRow>(\n `SELECT p.id, p.name, p.description, p.kind, p.price_cents, p.compare_at_cents,\n EXISTS (SELECT 1 FROM shop.product_variants v WHERE v.product_id = p.id AND ${SQL_AVAILABLE}) AS available\n FROM shop.products p\n WHERE p.id = $1 AND p.published AND p.deleted_at IS NULL`,\n [id],\n )\n const row = rows[0]\n if (row === undefined) return null\n\n const { rows: options } = await db.query<{\n id: string\n name: string\n values: string[] | null\n }>(\n `SELECT o.id, o.name,\n array_agg(DISTINCT vv.value) FILTER (WHERE vv.value IS NOT NULL) AS values\n FROM shop.product_options o\n LEFT JOIN shop.product_variant_values vv ON vv.option_id = o.id\n WHERE o.product_id = $1\n GROUP BY o.id, o.name, o.position\n ORDER BY o.position, o.name`,\n [id],\n )\n const { rows: variants } = await db.query<{\n id: string\n price_cents: number\n available: boolean\n choices: { option: string; valeur: string }[] | null\n }>(\n `SELECT v.id,\n coalesce(v.price_cents, p.price_cents) AS price_cents,\n ${SQL_AVAILABLE} AS available,\n (SELECT json_agg(json_build_object('option', o.name, 'valeur', vv.value) ORDER BY o.position, o.name)\n FROM shop.product_variant_values vv\n JOIN shop.product_options o ON o.id = vv.option_id\n WHERE vv.variant_id = v.id) AS choices\n FROM shop.product_variants v\n JOIN shop.products p ON p.id = v.product_id\n WHERE v.product_id = $1\n ORDER BY v.position, v.created_at`,\n [id],\n )\n const product = toProduct(row)\n const variantes: VarianteEnVitrine[] = variants.map((v) => {\n const choix = v.choices ?? []\n const price = Number(v.price_cents)\n return {\n id: v.id,\n libelle:\n choix.length === 0\n ? row.name\n : choix.map((c) => `${c.option} : ${c.valeur}`).join(' / '),\n choix,\n prixCentimes: price,\n prixBarreCentimes:\n row.compare_at_cents !== null && row.compare_at_cents > price\n ? Number(row.compare_at_cents)\n : null,\n disponible: v.available === true,\n visuelRang: null,\n prixUnitaire: null,\n }\n })\n return {\n ...product,\n seoTitre: row.name,\n seoResume: row.description,\n visuels: [],\n options: options.map((o) => ({ id: o.id, nom: o.name, valeurs: o.values ?? [] })),\n variantes,\n }\n}\n\nexport async function readCollections(db: Query): Promise<CollectionEnVitrine[]> {\n const { rows } = await db.query<{\n id: string\n name: string\n description: string\n n: number\n }>(\n `SELECT c.id, c.name, c.description,\n (SELECT count(*)::integer FROM shop.collection_products cp\n JOIN shop.products p ON p.id = cp.product_id\n WHERE cp.collection_id = c.id AND p.published AND p.deleted_at IS NULL) AS n\n FROM shop.collections c\n WHERE c.published\n ORDER BY c.name`,\n )\n return rows.map((c) => ({\n id: c.id,\n nom: c.name,\n description: c.description,\n combien: Number(c.n),\n }))\n}\n","/**\n * The cart, found by its token.\n *\n * The token travels in an `HttpOnly` cookie: no script of the page reads it,\n * so no third-party script injected into a site can copy it elsewhere. It is\n * set on a GESTURE (an add), never on a visit — a site that sets a cookie on\n * every page view would need a consent banner for it.\n *\n * The unit price is recorded at the add: checkout compares it with the price of\n * the day, and says so when it changed.\n *\n * @module\n */\n\nimport { randomBytes } from 'node:crypto'\n\nimport type { LigneDePanier, PanierEnVitrine } from '@odoro-cli/commerce'\n\nimport type { Query } from './base.js'\nimport { SQL_AVAILABLE } from './catalogue.js'\n\nexport const CART_COOKIE = 'odoro_panier'\n/** Thirty days: the window of an abandoned-cart reminder, no longer. */\nexport const CART_MAX_AGE = 60 * 60 * 24 * 30\n\nconst TOKEN = /^vitrine-[0-9a-f]{48}$/\n\nexport function isCartToken(value: string | undefined): value is string {\n return value !== undefined && TOKEN.test(value)\n}\n\nexport function newCartToken(): string {\n return `vitrine-${randomBytes(24).toString('hex')}`\n}\n\n/** Refused gestures carry the sentence the visitor reads. */\nexport class CartError extends Error {}\n\nexport async function findCart(\n db: Query,\n token: string | undefined,\n): Promise<{ id: string; token: string } | null> {\n if (!isCartToken(token)) return null\n const { rows } = await db.query<{ id: string }>(\n `SELECT id FROM shop.carts WHERE token = $1 AND state = 'ouvert' AND NOT preview`,\n [token],\n )\n const row = rows[0]\n return row === undefined ? null : { id: row.id, token }\n}\n\nexport async function openCart(db: Query): Promise<{ id: string; token: string }> {\n const token = newCartToken()\n const { rows } = await db.query<{ id: string }>(\n `INSERT INTO shop.carts (token) VALUES ($1) RETURNING id`,\n [token],\n )\n return { id: rows[0]!.id, token }\n}\n\nasync function touch(db: Query, cartId: string): Promise<void> {\n await db.query('UPDATE shop.carts SET updated_at = now() WHERE id = $1', [cartId])\n}\n\n/**\n * Adds a variant. The join on a PUBLISHED product is the guard: a guessed\n * variant id of a draft adds nothing.\n */\nexport async function addToCart(\n db: Query,\n cartId: string,\n variantId: string,\n quantity: number,\n): Promise<void> {\n const qty = Math.min(Math.max(Math.trunc(Number(quantity) || 1), 1), 99)\n const { rows } = await db.query<{ variant_id: string }>(\n `INSERT INTO shop.cart_lines (cart_id, variant_id, quantity, unit_price_cents)\n SELECT $1, v.id, $3, coalesce(v.price_cents, p.price_cents)\n FROM shop.product_variants v\n JOIN shop.products p ON p.id = v.product_id\n WHERE v.id = $2 AND p.published AND p.deleted_at IS NULL\n ON CONFLICT (cart_id, variant_id)\n DO UPDATE SET quantity = least(shop.cart_lines.quantity + excluded.quantity, 99)\n RETURNING variant_id`,\n [cartId, variantId, qty],\n )\n if (rows.length === 0) throw new CartError(\"Cet article n'est plus en vente.\")\n await touch(db, cartId)\n}\n\n/** A quantity. Zero removes the line — the gesture the screen expects. */\nexport async function setQuantity(\n db: Query,\n cartId: string,\n variantId: string,\n quantity: number,\n): Promise<void> {\n const qty = Math.min(Math.max(Math.trunc(Number(quantity) || 0), 0), 99)\n if (qty === 0) {\n await db.query('DELETE FROM shop.cart_lines WHERE cart_id = $1 AND variant_id = $2', [\n cartId,\n variantId,\n ])\n } else {\n await db.query(\n 'UPDATE shop.cart_lines SET quantity = $3 WHERE cart_id = $1 AND variant_id = $2',\n [cartId, variantId, qty],\n )\n }\n await touch(db, cartId)\n}\n\nexport async function readCart(\n db: Query,\n cart: { id: string; token: string },\n isNew = false,\n): Promise<PanierEnVitrine> {\n const { rows } = await db.query<{\n variant_id: string\n product_id: string\n name: string\n label: string | null\n unit_price_cents: number\n quantity: number\n available: boolean\n }>(\n `SELECT l.variant_id, v.product_id, p.name,\n (SELECT string_agg(vv.value, ' / ' ORDER BY o.position)\n FROM shop.product_variant_values vv\n JOIN shop.product_options o ON o.id = vv.option_id\n WHERE vv.variant_id = v.id) AS label,\n l.unit_price_cents, l.quantity,\n (p.published AND p.deleted_at IS NULL AND ${SQL_AVAILABLE}) AS available\n FROM shop.cart_lines l\n JOIN shop.product_variants v ON v.id = l.variant_id\n JOIN shop.products p ON p.id = v.product_id\n WHERE l.cart_id = $1\n ORDER BY l.added_at, l.variant_id`,\n [cart.id],\n )\n const { rows: head } = await db.query<{ email: string | null }>(\n 'SELECT email FROM shop.carts WHERE id = $1',\n [cart.id],\n )\n const lignes: LigneDePanier[] = rows.map((l) => ({\n varianteId: l.variant_id,\n produitId: l.product_id,\n nom: l.name,\n declinaison: l.label ?? '',\n prixUnitaireCentimes: Number(l.unit_price_cents),\n quantite: Number(l.quantity),\n sousTotalCentimes: Number(l.unit_price_cents) * Number(l.quantity),\n disponible: l.available === true,\n visuel: null,\n }))\n return {\n panierId: cart.id,\n jeton: cart.token,\n courriel: head[0]?.email ?? null,\n lignes,\n combien: lignes.reduce((n, l) => n + l.quantite, 0),\n sousTotalCentimes: lignes.reduce((n, l) => n + l.sousTotalCentimes, 0),\n neuf: isNew,\n }\n}\n","/**\n * Checkout: the order lives in the site's database, the MONEY does not.\n *\n * Decided with the founder of Odoro (DECISIONS §43 of the main repository):\n * the payment stays with Odoro (Whop), which knows money and its obligations.\n * This module opens the order, reserves the stock, and hands the payment to a\n * PORT the app wires — Odoro's payment API. The order only keeps the opaque\n * reference the payment returned; it learns the outcome through a SIGNED\n * callback (`confirmPayment`), never from the visitor's browser.\n *\n * The three gestures of the storefront contract:\n *\n * · `ouvrir` — reprices the cart at today's prices (and says what changed),\n * checks and reserves the stock for thirty minutes, writes the order;\n * · `chiffrer` — the total. Discount codes are not served by this module\n * yet: a code is refused by name, never silently ignored;\n * · `payer` — the payment page, from the port.\n *\n * @module\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto'\n\nimport type { Base, Query } from './base.js'\nimport { CartError } from './cart.js'\n\n/** A refused checkout gesture: `status` and the sentence the visitor reads. */\nexport class CheckoutError extends Error {\n constructor(\n readonly status: 400 | 404 | 409,\n message: string,\n ) {\n super(message)\n }\n}\n\n/** What the visitor typed, under the storefront contract's names. */\nexport interface CheckoutInput {\n courriel: string\n nom: string\n ligne1: string\n ligne2?: string\n code_postal: string\n ville: string\n pays: string\n telephone?: string\n}\n\n/** Where the money goes: Odoro's payment, wired by the app. */\nexport interface PaymentPort {\n open(input: {\n readonly orderId: string\n readonly number: number\n readonly totalCents: number\n readonly currency: string\n readonly email: string\n }): Promise<{ readonly reference: string; readonly paymentUrl: string }>\n}\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n/** How long a checkout holds the stock. */\nconst RESERVATION_MINUTES = 30\n\nfunction need(value: string | undefined, message: string): string {\n const v = (value ?? '').trim()\n if (v === '') throw new CheckoutError(400, message)\n return v\n}\n\ninterface OrderRow {\n id: string\n number: string | number\n state: string\n email: string\n currency: string\n subtotal_cents: number\n shipping_cents: number\n tax_cents: number\n total_cents: number\n payment_ref: string | null\n}\n\nexport async function quote(db: Query, orderId: string) {\n const { rows } = await db.query<OrderRow>('SELECT * FROM shop.orders WHERE id = $1', [\n orderId,\n ])\n const order = rows[0]\n if (order === undefined) throw new CheckoutError(404, \"Cette caisse n'existe pas.\")\n const { rows: lines } = await db.query<{\n variant_id: string | null\n product_id: string | null\n product_name: string\n unit_price_cents: number\n quantity: number\n }>(\n `SELECT l.variant_id, v.product_id, l.product_name, l.unit_price_cents, l.quantity\n FROM shop.order_lines l LEFT JOIN shop.product_variants v ON v.id = l.variant_id\n WHERE l.order_id = $1 ORDER BY l.product_name`,\n [orderId],\n )\n return {\n caisseId: order.id,\n devise: order.currency,\n lignes: lines.map((l) => ({\n varianteId: l.variant_id ?? '',\n produitId: l.product_id ?? '',\n nom: l.product_name,\n genre: 'physique',\n prixUnitaireCentimes: Number(l.unit_price_cents),\n quantite: Number(l.quantity),\n sousTotalCentimes: Number(l.unit_price_cents) * Number(l.quantity),\n taxable: true,\n })),\n remises: [],\n carteCadeau: null,\n methodeDeLivraison: null,\n sousTotalCentimes: Number(order.subtotal_cents),\n remiseCentimes: 0,\n portCentimes: Number(order.shipping_cents),\n taxeCentimes: Number(order.tax_cents),\n carteCadeauCentimes: 0,\n totalCentimes: Number(order.total_cents),\n taxeIncluse: true,\n }\n}\n\nexport async function openCheckout(\n db: Base,\n cartId: string,\n input: CheckoutInput,\n currency = 'EUR',\n): Promise<{\n orderId: string\n repriced: { varianteId: string; ancienCentimes: number; nouveauCentimes: number }[]\n}> {\n const email = need(input.courriel, 'Indiquez votre adresse e-mail.')\n if (!EMAIL.test(email))\n throw new CheckoutError(400, \"Cette adresse e-mail n'est pas valable.\")\n const name = need(input.nom, 'Indiquez votre nom.')\n const address = {\n ligne1: need(input.ligne1, 'Indiquez votre adresse.'),\n ligne2: (input.ligne2 ?? '').trim(),\n code_postal: need(input.code_postal, 'Indiquez votre code postal.'),\n ville: need(input.ville, 'Indiquez votre ville.'),\n pays: need(input.pays, 'Indiquez votre pays.'),\n telephone: (input.telephone ?? '').trim(),\n }\n\n return await db.transaction(async (tx) => {\n // A checkout reopened on the same cart replaces the previous one: its\n // reservation is released, and only one open order stays.\n await tx.query(\n `UPDATE shop.stock_reservations SET state = 'relachee'\n WHERE cart_id = $1 AND state = 'ouverte'`,\n [cartId],\n )\n await tx.query(\n `UPDATE shop.orders SET state = 'annulee'\n WHERE cart_id = $1 AND state = 'ouverte' AND payment_ref IS NULL`,\n [cartId],\n )\n\n const { rows: lines } = await tx.query<{\n variant_id: string\n quantity: number\n unit_price_cents: number\n current_cents: number\n stock: number | null\n for_sale: boolean\n name: string\n label: string | null\n }>(\n `SELECT l.variant_id, l.quantity, l.unit_price_cents,\n coalesce(v.price_cents, p.price_cents) AS current_cents,\n v.stock, (p.published AND p.deleted_at IS NULL) AS for_sale, p.name,\n (SELECT string_agg(vv.value, ' / ' ORDER BY o.position)\n FROM shop.product_variant_values vv JOIN shop.product_options o ON o.id = vv.option_id\n WHERE vv.variant_id = v.id) AS label\n FROM shop.cart_lines l\n JOIN shop.product_variants v ON v.id = l.variant_id\n JOIN shop.products p ON p.id = v.product_id\n WHERE l.cart_id = $1\n ORDER BY l.variant_id\n FOR UPDATE OF v`,\n [cartId],\n )\n if (lines.length === 0) throw new CartError('Votre panier est vide.')\n const gone = lines.find((l) => !l.for_sale)\n if (gone !== undefined) {\n throw new CheckoutError(\n 409,\n `« ${gone.name} » n'est plus en vente : retirez-le de votre panier.`,\n )\n }\n\n for (const l of lines) {\n if (l.stock === null) continue\n const { rows } = await tx.query<{ held: number }>(\n `SELECT coalesce(sum(quantity), 0)::integer AS held FROM shop.stock_reservations\n WHERE variant_id = $1 AND state = 'ouverte' AND expires_at > now()`,\n [l.variant_id],\n )\n if (Number(l.stock) - Number(rows[0]?.held ?? 0) < Number(l.quantity)) {\n throw new CheckoutError(409, `« ${l.name} » : il n'en reste pas assez.`)\n }\n }\n\n const repriced = lines\n .filter((l) => Number(l.current_cents) !== Number(l.unit_price_cents))\n .map((l) => ({\n varianteId: l.variant_id,\n ancienCentimes: Number(l.unit_price_cents),\n nouveauCentimes: Number(l.current_cents),\n }))\n for (const r of repriced) {\n await tx.query(\n 'UPDATE shop.cart_lines SET unit_price_cents = $3 WHERE cart_id = $1 AND variant_id = $2',\n [cartId, r.varianteId, r.nouveauCentimes],\n )\n }\n\n const subtotal = lines.reduce(\n (n, l) => n + Number(l.current_cents) * Number(l.quantity),\n 0,\n )\n const { rows: created } = await tx.query<{ id: string }>(\n `INSERT INTO shop.orders (cart_id, email, customer_name, shipping_address, currency, subtotal_cents, total_cents)\n VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING id`,\n [cartId, email, name, JSON.stringify(address), currency, subtotal],\n )\n const orderId = created[0]!.id\n for (const l of lines) {\n await tx.query(\n `INSERT INTO shop.order_lines (order_id, variant_id, product_name, variant_label, unit_price_cents, quantity)\n VALUES ($1, $2, $3, $4, $5, $6)`,\n [orderId, l.variant_id, l.name, l.label ?? '', l.current_cents, l.quantity],\n )\n if (l.stock !== null) {\n await tx.query(\n `INSERT INTO shop.stock_reservations (variant_id, cart_id, quantity, expires_at)\n VALUES ($1, $2, $3, now() + make_interval(mins => $4))`,\n [l.variant_id, cartId, l.quantity, RESERVATION_MINUTES],\n )\n }\n }\n await tx.query('UPDATE shop.carts SET email = $2, updated_at = now() WHERE id = $1', [\n cartId,\n email,\n ])\n return { orderId, repriced }\n })\n}\n\n/** The quote with a code. Codes are not served here yet: refused by name. */\nexport async function applyCode(db: Query, orderId: string, code: string | undefined) {\n if ((code ?? '').trim() !== '') {\n throw new CheckoutError(409, \"Ce code n'est pas reconnu par cette boutique.\")\n }\n return await quote(db, orderId)\n}\n\nexport async function pay(db: Query, orderId: string, port: PaymentPort) {\n const { rows } = await db.query<OrderRow & { expired: boolean }>(\n `SELECT o.*,\n EXISTS (SELECT 1 FROM shop.stock_reservations r\n WHERE r.cart_id = o.cart_id AND r.state = 'ouverte' AND r.expires_at <= now()) AS expired\n FROM shop.orders o WHERE o.id = $1`,\n [orderId],\n )\n const order = rows[0]\n if (order === undefined) throw new CheckoutError(404, \"Cette caisse n'existe pas.\")\n if (order.state !== 'ouverte')\n throw new CheckoutError(409, 'Cette commande ne peut plus être payée.')\n if (order.expired)\n throw new CheckoutError(409, 'Votre réservation a expiré : rouvrez la caisse.')\n\n const opened = await port.open({\n orderId: order.id,\n number: Number(order.number),\n totalCents: Number(order.total_cents),\n currency: order.currency,\n email: order.email,\n })\n await db.query('UPDATE shop.orders SET payment_ref = $2 WHERE id = $1', [\n order.id,\n opened.reference,\n ])\n const total = Number(order.total_cents)\n return {\n commandeId: order.id,\n numero: Number(order.number),\n jetonDeSuivi: order.id,\n totalCentimes: total,\n totalTexte: new Intl.NumberFormat('fr', {\n style: 'currency',\n currency: order.currency,\n }).format(total / 100),\n adresseDePaiement: opened.paymentUrl,\n }\n}\n\n/** How long a signed callback stays valid. */\nconst CALLBACK_WINDOW_S = 300\n\n/** The signature of a payment callback: HMAC-SHA256 of `reference.state.timestamp`. */\nexport function signCallback(\n secret: string,\n reference: string,\n state: string,\n timestamp: number,\n): string {\n return createHmac('sha256', secret)\n .update(`${reference}.${state}.${timestamp}`)\n .digest('hex')\n}\n\nexport function verifyCallback(\n secret: string,\n input: { reference: string; etat: string; horodatage: number; signature: string },\n now = Date.now(),\n): boolean {\n if (secret === '' || Math.abs(now / 1000 - input.horodatage) > CALLBACK_WINDOW_S)\n return false\n const expected = Buffer.from(\n signCallback(secret, input.reference, input.etat, input.horodatage),\n )\n const received = Buffer.from(input.signature)\n return expected.length === received.length && timingSafeEqual(expected, received)\n}\n\n/**\n * The payment's outcome, from Odoro. Idempotent: a callback delivered twice\n * changes nothing the second time.\n */\nexport async function confirmPayment(\n db: Base,\n reference: string,\n state: 'payee' | 'echouee',\n): Promise<boolean> {\n return await db.transaction(async (tx) => {\n const { rows } = await tx.query<{\n id: string\n cart_id: string | null\n state: string\n }>('SELECT id, cart_id, state FROM shop.orders WHERE payment_ref = $1 FOR UPDATE', [\n reference,\n ])\n const order = rows[0]\n if (order === undefined) return false\n if (order.state !== 'ouverte') return true\n\n if (state === 'echouee') {\n await tx.query(`UPDATE shop.orders SET state = 'echouee' WHERE id = $1`, [order.id])\n await tx.query(\n `UPDATE shop.stock_reservations SET state = 'relachee' WHERE cart_id = $1 AND state = 'ouverte'`,\n [order.cart_id],\n )\n return true\n }\n\n await tx.query(\n `UPDATE shop.orders SET state = 'payee', paid_at = now() WHERE id = $1`,\n [order.id],\n )\n // The stock leaves when the money arrives, not before.\n await tx.query(\n `UPDATE shop.product_variants v\n SET stock = greatest(v.stock - l.quantity, 0)\n FROM shop.order_lines l\n WHERE l.order_id = $1 AND l.variant_id = v.id AND v.stock IS NOT NULL`,\n [order.id],\n )\n await tx.query(\n `UPDATE shop.stock_reservations SET state = 'consommee' WHERE cart_id = $1 AND state = 'ouverte'`,\n [order.cart_id],\n )\n await tx.query(\n `UPDATE shop.carts SET state = 'commande', updated_at = now() WHERE id = $1`,\n [order.cart_id],\n )\n return true\n })\n}\n\n/** An order's state, for the tracking link the buyer received. */\nexport async function orderState(db: Query, orderId: string) {\n if (!/^[0-9a-f-]{36}$/i.test(orderId)) return null\n const { rows } = await db.query<OrderRow & { created_at: Date }>(\n 'SELECT * FROM shop.orders WHERE id = $1',\n [orderId],\n )\n const order = rows[0]\n if (order === undefined) return null\n const { rows: lines } = await db.query<{\n product_name: string\n quantity: number\n unit_price_cents: number\n }>(\n 'SELECT product_name, quantity, unit_price_cents FROM shop.order_lines WHERE order_id = $1 ORDER BY product_name',\n [orderId],\n )\n return {\n numero: Number(order.number),\n etat: order.state,\n courriel: order.email,\n totalCentimes: Number(order.total_cents),\n passeeLe: new Date(order.created_at).toISOString(),\n lignes: lines.map((l) => ({\n nom: l.product_name,\n quantite: Number(l.quantity),\n sousTotalCentimes: Number(l.unit_price_cents) * Number(l.quantity),\n })),\n livraisons: [],\n }\n}\n","/**\n * The storefront contract, as an `@odoro-cli/server` module.\n *\n * A site that sells, running in its own container with its own database,\n * mounts this module and serves `/api/storefront/*` itself — the same answers\n * as Odoro's central storefront, so the V4 script (`odoro-boutique.js`) and\n * `@odoro-cli/commerce` talk to it without knowing which one they reach.\n *\n * ## What it serves, and what it does not yet\n *\n * The catalogue, product pages, collections, the cart, checkout (open, quote,\n * pay through Odoro) and order tracking. Customer accounts and discount codes\n * are not served here yet: they answer so, by name — an account request says\n * the account is not available, a code says it is not recognised. A gesture\n * that silently does nothing is worse than one that says no.\n *\n * ## Errors speak the contract\n *\n * The storefront's clients read `erreur`. Every refusal is a problem document\n * (RFC 9457, like the rest of the kernel) that also carries `erreur`.\n *\n * @module\n */\n\nimport {\n ApiError,\n ConflictError,\n NotFoundError,\n ServiceUnavailableError,\n defineModule,\n route,\n type Cookies,\n} from '@odoro-cli/server'\nimport { z } from 'zod'\n\nimport { type Base, shopInstalled } from './base.js'\nimport {\n CART_COOKIE,\n CART_MAX_AGE,\n CartError,\n addToCart,\n findCart,\n openCart,\n readCart,\n setQuantity,\n} from './cart.js'\nimport { readCatalogue, readCollections, readProduct } from './catalogue.js'\nimport {\n CheckoutError,\n applyCode,\n confirmPayment,\n openCheckout,\n orderState,\n pay,\n quote,\n verifyCallback,\n type PaymentPort,\n} from './checkout.js'\n\nexport interface CommerceOptions {\n /** The site's shop database (`@odoro-cli/cloud-connect`, or any `Base`). */\n readonly db: Base\n /** Where the money goes: Odoro's payment. */\n readonly payment: PaymentPort\n /** The secret Odoro signs its payment callbacks with. Empty = callbacks refused. */\n readonly callbackSecret: string\n /** The shop's currency. @defaultValue 'EUR' */\n readonly currency?: string\n /** Mark the cart cookie `Secure`. @defaultValue true */\n readonly secureCookie?: boolean\n}\n\nfunction refusal(\n kind: 'VALIDATION' | 'NOT_FOUND' | 'CONFLICT' | 'UNAVAILABLE',\n message: string,\n): ApiError {\n const extensions = { extensions: { erreur: message } }\n if (kind === 'NOT_FOUND') return new NotFoundError(message, extensions)\n if (kind === 'CONFLICT') return new ConflictError(message, extensions)\n if (kind === 'UNAVAILABLE')\n return new ServiceUnavailableError(message, undefined, extensions)\n return new ApiError('VALIDATION', message, extensions)\n}\n\n/** Domain refusals become contract errors; anything else stays an internal error. */\nasync function speaking<T>(work: () => Promise<T>): Promise<T> {\n try {\n return await work()\n } catch (cause) {\n if (cause instanceof CheckoutError) {\n throw refusal(\n cause.status === 404\n ? 'NOT_FOUND'\n : cause.status === 409\n ? 'CONFLICT'\n : 'VALIDATION',\n cause.message,\n )\n }\n if (cause instanceof CartError) throw refusal('CONFLICT', cause.message)\n throw cause\n }\n}\n\nconst text = z.string().max(200).optional()\nconst count = z.coerce.number().int().min(0).max(100_000).optional()\n\nexport function createCommerceModule(options: CommerceOptions) {\n const { db } = options\n const currency = options.currency ?? 'EUR'\n let installed: Promise<boolean> | null = null\n\n /** Once per process: the `shop` capability, at a version this module reads. */\n async function ready(): Promise<void> {\n installed ??= shopInstalled(db)\n if (!(await installed)) {\n installed = null\n throw refusal('UNAVAILABLE', \"La boutique de ce site n'est pas encore installée.\")\n }\n }\n\n async function cartOf(cookies: Cookies) {\n return await findCart(db, cookies.get(CART_COOKIE))\n }\n\n function keep(cookies: Cookies, token: string): void {\n cookies.set(CART_COOKIE, token, {\n httpOnly: true,\n sameSite: 'lax',\n maxAge: CART_MAX_AGE,\n ...(options.secureCookie === false ? { secure: false } : {}),\n })\n }\n\n const storefront = route({\n name: 'storefront.read',\n method: 'GET',\n path: '/api/storefront',\n auth: 'public',\n input: z.object({\n geste: z\n .enum(['catalogue', 'fiche', 'collections', 'commande', 'suggestions'])\n .default('catalogue'),\n produit: text,\n jeton: text,\n q: text,\n collection: text,\n genre: text,\n prix_min: count,\n prix_max: count,\n disponibles: z.string().optional(),\n tri: z\n .enum(['nouveautes', 'prix_croissant', 'prix_decroissant', 'nom'])\n .optional()\n .catch(undefined),\n combien: count,\n depuis: count,\n }),\n summary:\n 'The catalogue, a product page, the collections or an order, per the storefront contract.',\n handler: async ({ input }) => {\n await ready()\n if (input.geste === 'fiche') {\n const fiche = await readProduct(db, input.produit ?? '')\n if (fiche === null) throw refusal('NOT_FOUND', \"Cet article n'est plus en vente.\")\n return { fiche }\n }\n if (input.geste === 'collections') return { collections: await readCollections(db) }\n if (input.geste === 'commande') {\n const commande = await orderState(db, input.jeton ?? '')\n if (commande === null) throw refusal('NOT_FOUND', \"Cette commande n'existe pas.\")\n return { commande }\n }\n if (input.geste === 'suggestions') {\n const { produits } = await readCatalogue(db, { q: input.q ?? '', combien: 6 })\n return { suggestions: produits.map((p) => ({ id: p.id, nom: p.nom })) }\n }\n return await readCatalogue(db, {\n ...(input.q === undefined ? {} : { q: input.q }),\n ...(input.collection === undefined ? {} : { collection: input.collection }),\n ...(input.genre === undefined ? {} : { genre: input.genre }),\n ...(input.prix_min === undefined ? {} : { prixMin: input.prix_min }),\n ...(input.prix_max === undefined ? {} : { prixMax: input.prix_max }),\n disponibles: input.disponibles === '1',\n ...(input.tri === undefined ? {} : { tri: input.tri }),\n ...(input.combien === undefined ? {} : { combien: input.combien }),\n ...(input.depuis === undefined ? {} : { depuis: input.depuis }),\n })\n },\n })\n\n /** A page view. Nothing is counted or stored here: no tracker on the site. */\n const visit = route({\n name: 'storefront.visit',\n method: 'POST',\n path: '/api/storefront',\n auth: 'public',\n handler: () => ({ ok: true }),\n })\n\n const readCartRoute = route({\n name: 'storefront.cart.read',\n method: 'GET',\n path: '/api/storefront/cart',\n auth: 'public',\n handler: async ({ cookies }) => {\n await ready()\n const cart = await cartOf(cookies)\n if (cart === null) {\n return {\n panier: {\n panierId: '',\n jeton: '',\n courriel: null,\n lignes: [],\n combien: 0,\n sousTotalCentimes: 0,\n neuf: false,\n },\n }\n }\n return { panier: await readCart(db, cart) }\n },\n })\n\n const changeCart = route({\n name: 'storefront.cart.change',\n method: 'POST',\n path: '/api/storefront/cart',\n auth: 'public',\n input: z.object({\n geste: z.enum(['ajouter', 'quantite']).default('ajouter'),\n variante: z.string().regex(/^[0-9a-f-]{36}$/i, \"Cet article n'est plus en vente.\"),\n quantite: z.coerce.number().int().min(0).max(99).default(1),\n }),\n handler: async ({ input, cookies }) =>\n await speaking(async () => {\n await ready()\n const existing = await cartOf(cookies)\n const cart = existing ?? (await openCart(db))\n if (input.geste === 'quantite')\n await setQuantity(db, cart.id, input.variante, input.quantite)\n else await addToCart(db, cart.id, input.variante, input.quantite)\n // The cookie is set on a GESTURE, never on a visit.\n if (existing === null) keep(cookies, cart.token)\n return { panier: await readCart(db, cart, existing === null) }\n }),\n })\n\n const checkout = route({\n name: 'storefront.checkout',\n method: 'POST',\n path: '/api/storefront/checkout',\n auth: 'public',\n input: z.object({\n geste: z.enum(['ouvrir', 'chiffrer', 'payer']),\n caisse: z.string().max(64).optional(),\n code: z.string().max(40).optional(),\n courriel: text,\n nom: text,\n ligne1: text,\n ligne2: text,\n code_postal: z.string().max(20).optional(),\n ville: text,\n pays: z.string().max(40).optional(),\n telephone: z.string().max(30).optional(),\n }),\n handler: async ({ input, cookies }) =>\n await speaking(async () => {\n await ready()\n if (input.geste === 'ouvrir') {\n const cart = await cartOf(cookies)\n if (cart === null) throw new CheckoutError(400, 'Votre panier est vide.')\n const opened = await openCheckout(\n db,\n cart.id,\n {\n courriel: input.courriel ?? '',\n nom: input.nom ?? '',\n ligne1: input.ligne1 ?? '',\n ligne2: input.ligne2 ?? '',\n code_postal: input.code_postal ?? '',\n ville: input.ville ?? '',\n pays: input.pays ?? '',\n telephone: input.telephone ?? '',\n },\n currency,\n )\n return {\n caisse: opened.orderId,\n prixRevalorises: opened.repriced,\n total: await quote(db, opened.orderId),\n }\n }\n const orderId = input.caisse ?? ''\n if (!/^[0-9a-f-]{36}$/i.test(orderId))\n throw new CheckoutError(404, \"Cette caisse n'existe pas.\")\n if (input.geste === 'chiffrer')\n return { total: await applyCode(db, orderId, input.code) }\n await applyCode(db, orderId, input.code)\n return await pay(db, orderId, options.payment)\n }),\n })\n\n /** Odoro tells the payment's outcome. Signed: the browser never decides that an order is paid. */\n const callback = route({\n name: 'storefront.payment.callback',\n method: 'POST',\n path: '/api/storefront/payment-callback',\n auth: 'public',\n input: z.object({\n reference: z.string().min(1).max(200),\n etat: z.enum(['payee', 'echouee']),\n horodatage: z.coerce.number().int(),\n signature: z.string().min(1).max(128),\n }),\n handler: async ({ input }) => {\n await ready()\n if (!verifyCallback(options.callbackSecret, input)) {\n throw refusal('NOT_FOUND', 'Rappel de paiement refusé.')\n }\n const known = await confirmPayment(db, input.reference, input.etat)\n if (!known) throw refusal('NOT_FOUND', 'Paiement inconnu.')\n return { ok: true }\n },\n })\n\n /** Not served by this module yet — said by name, never a dead button. */\n const accountRead = route({\n name: 'storefront.account.read',\n method: 'GET',\n path: '/api/storefront/account',\n auth: 'public',\n handler: () => ({ connecte: false }),\n })\n const accountWrite = route({\n name: 'storefront.account.write',\n method: 'POST',\n path: '/api/storefront/account',\n auth: 'public',\n handler: () => {\n throw refusal(\n 'UNAVAILABLE',\n \"Le compte client n'est pas encore disponible sur cette boutique.\",\n )\n },\n })\n\n return defineModule({\n name: 'commerce',\n routes: [\n storefront,\n visit,\n readCartRoute,\n changeCart,\n checkout,\n callback,\n accountRead,\n accountWrite,\n ] as never,\n })\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@odoro-cli/server-commerce",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"author": "BouBouw",
|
|
7
|
+
"description": "The Odoro storefront contract, served by an @odoro-cli/server app from its own shop database.",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22"
|
|
10
|
+
},
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"zod": "^4.5.1",
|
|
24
|
+
"@odoro-cli/commerce": "^0.1.1"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@odoro-cli/server": ">=1.2.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/express": "^5.0.3",
|
|
31
|
+
"@types/node": "^22.18.8",
|
|
32
|
+
"@types/pg": "^8.15.6",
|
|
33
|
+
"@types/supertest": "^6.0.2",
|
|
34
|
+
"express": "^5.1.0",
|
|
35
|
+
"pg": "^8.16.3",
|
|
36
|
+
"supertest": "^7.1.1",
|
|
37
|
+
"tsup": "^8.5.0",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vitest": "^3.2.4",
|
|
40
|
+
"@odoro-cli/server": "1.2.0"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://odoro.dev",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/ODORO-CLI/OdoroKit.git",
|
|
46
|
+
"directory": "packages/odoro-server-commerce"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/ODORO-CLI/OdoroKit/issues"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"typecheck": "tsc --noEmit"
|
|
55
|
+
}
|
|
56
|
+
}
|