@develemit/commerce-server 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -2
- package/dist/index.d.ts +1507 -0
- package/dist/index.js +2444 -0
- package/dist/index.js.map +1 -0
- package/docs/admin.md +88 -0
- package/docs/payments.md +41 -0
- package/migrations/0000_simple_lethal_legion.sql +29 -0
- package/migrations/0001_checkout_holds_orders.sql +45 -0
- package/migrations/0002_shipping_rates_and_order_shipping.sql +15 -0
- package/migrations/0003_product_image_storage_key.sql +1 -0
- package/migrations/0004_order_needs_attention.sql +1 -0
- package/migrations/0005_order_management.sql +21 -0
- package/migrations/0006_refund_idempotency.sql +2 -0
- package/migrations/meta/0000_snapshot.json +207 -0
- package/migrations/meta/0001_snapshot.json +517 -0
- package/migrations/meta/0002_snapshot.json +607 -0
- package/migrations/meta/0003_snapshot.json +613 -0
- package/migrations/meta/0004_snapshot.json +614 -0
- package/migrations/meta/0005_snapshot.json +752 -0
- package/migrations/meta/0006_snapshot.json +766 -0
- package/migrations/meta/_journal.json +55 -0
- package/package.json +60 -3
package/README.md
CHANGED
|
@@ -1,3 +1,210 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @develemit/commerce-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fastify plugins for a small shop: the public catalog, no-oversell inventory
|
|
4
|
+
holds, Stripe hosted checkout, and orders. The package ships its own Drizzle
|
|
5
|
+
schema and migrations, and the host owns the database pool and the secrets.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @develemit/commerce-server fastify drizzle-orm pg
|
|
11
|
+
pnpm add stripe # only if you use createStripeGateway
|
|
12
|
+
pnpm add @fastify/multipart # only if you pass `admin`
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Integrate
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import Fastify from 'fastify';
|
|
19
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
20
|
+
import { Pool } from 'pg';
|
|
21
|
+
import {
|
|
22
|
+
commercePlugin,
|
|
23
|
+
commerceSchema,
|
|
24
|
+
createStripeGateway,
|
|
25
|
+
runCommerceMigrations,
|
|
26
|
+
startHoldSweeper,
|
|
27
|
+
} from '@develemit/commerce-server';
|
|
28
|
+
|
|
29
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
30
|
+
const db = drizzle(pool, { schema: commerceSchema });
|
|
31
|
+
await runCommerceMigrations(db); // history lives in __commerce_migrations
|
|
32
|
+
|
|
33
|
+
const app = Fastify();
|
|
34
|
+
await app.register(commercePlugin, {
|
|
35
|
+
prefix: '/api/commerce',
|
|
36
|
+
db,
|
|
37
|
+
currency: 'USD',
|
|
38
|
+
payment: createStripeGateway({
|
|
39
|
+
secretKey: process.env.STRIPE_SECRET_KEY!,
|
|
40
|
+
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
41
|
+
}),
|
|
42
|
+
urls: {
|
|
43
|
+
success: (sessionId) =>
|
|
44
|
+
`https://shop.example/checkout/success?session_id=${sessionId}`,
|
|
45
|
+
cancel: 'https://shop.example/cart',
|
|
46
|
+
},
|
|
47
|
+
shippingCountries: ['US'], // default
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const sweeper = startHoldSweeper(db, { intervalMs: 60_000 });
|
|
51
|
+
app.addHook('onClose', async () => sweeper.stop());
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`payment` and `urls` go together. Omit both (`commercePlugin({ db, currency })`)
|
|
55
|
+
for a catalog-only mount: the checkout, webhook, and order routes aren't
|
|
56
|
+
registered, so `/checkout` returns 404.
|
|
57
|
+
|
|
58
|
+
## Category labels
|
|
59
|
+
|
|
60
|
+
`GET /catalog/categories` labels each category with its slug. Pass
|
|
61
|
+
`categoryLabels` (a record, or a function returning `undefined` for no label)
|
|
62
|
+
to show display names; unmatched slugs keep the slug.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
categoryLabels: { 'wall-art': 'Wall Art', 'one-of-a-kind': 'One of a Kind' }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Shipping rates and order hooks
|
|
69
|
+
|
|
70
|
+
Active rows in `commerce_shipping_rates` (in the checkout currency, ordered by
|
|
71
|
+
`sort`) are sent to Stripe as `shipping_options`. The buyer's choice is stored
|
|
72
|
+
on the order as `shippingCents` and `shippingLabel`, and `totalCents` is
|
|
73
|
+
subtotal plus shipping. Load rates with `seedShippingRates(db, rates)`, an
|
|
74
|
+
idempotent upsert on `label`. With no active rates the session has no
|
|
75
|
+
shipping options.
|
|
76
|
+
|
|
77
|
+
The package sends no email. Pass `hooks` and send your own:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
hooks: {
|
|
81
|
+
onOrderPaid: async (order) => mailer.send(confirmation(order)),
|
|
82
|
+
onOrderExpired: async (order) => {},
|
|
83
|
+
onOrderNeedsAttention: async (order) => notifyStaffToRefund(order),
|
|
84
|
+
onOrderPaymentFailed: async (order) => mailer.send(paymentFailed(order)),
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| Hook | Fires when |
|
|
89
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
90
|
+
| `onOrderPaid` | Payment succeeded and stock was committed. |
|
|
91
|
+
| `onOrderExpired` | The checkout session expired unpaid; stock is released. |
|
|
92
|
+
| `onOrderNeedsAttention` | Payment succeeded but the stock is gone (see [Orders that need attention](docs/admin.md#orders-that-need-attention)). |
|
|
93
|
+
| `onOrderPaymentFailed` | An async payment (e.g. bank debit) failed; stock is released. |
|
|
94
|
+
| `onOrderShipped` | The admin first marks a paid order shipped (not on a tracking edit). |
|
|
95
|
+
| `onOrderRefunded` | An admin refund is recorded. Receives `(order, refund)`. |
|
|
96
|
+
|
|
97
|
+
`onOrderNeedsAttention` fires when a payment succeeded but the stock is gone
|
|
98
|
+
(see [Orders that need attention](docs/admin.md#orders-that-need-attention)). The order is `needs_attention`; refund it with
|
|
99
|
+
`POST /admin/orders/:id/refunds`. All hooks receive an `OrderDetail` (items, totals, email, address). Hooks run after
|
|
100
|
+
the webhook transaction commits, once per processed event, so a replayed event
|
|
101
|
+
never re-fires them. A hook that throws is logged and the webhook still
|
|
102
|
+
returns 200, so make hooks idempotent and do your own retry if delivery
|
|
103
|
+
matters.
|
|
104
|
+
|
|
105
|
+
The package never reads `process.env`; pass everything in. Load products with
|
|
106
|
+
`seedProducts(db, products)`, an idempotent upsert on `slug`.
|
|
107
|
+
|
|
108
|
+
## Running a shop
|
|
109
|
+
|
|
110
|
+
The order lifecycle, from the host's side:
|
|
111
|
+
|
|
112
|
+
1. **Buy.** Checkout creates a `pending` order and holds stock. The payment
|
|
113
|
+
webhook makes it `paid` and fires `onOrderPaid` (send the confirmation).
|
|
114
|
+
2. **Ship.** `POST /admin/orders/:id/ship` with carrier and tracking. The order
|
|
115
|
+
is `shipped` and `onOrderShipped` fires once (send the tracking email). The
|
|
116
|
+
buyer's order lookup now includes `fulfillment`.
|
|
117
|
+
3. **Refund.** `POST /admin/orders/:id/refunds` with an `Idempotency-Key`,
|
|
118
|
+
optionally `restock: true`. A full refund makes the order `refunded` and
|
|
119
|
+
fires `onOrderRefunded`. Refunds made in the Stripe dashboard arrive through
|
|
120
|
+
the `charge.refunded` webhook and fire the same hook.
|
|
121
|
+
4. **Stuck orders.** A paid order whose stock is gone is `needs_attention`
|
|
122
|
+
(`onOrderNeedsAttention`). Resolve it or refund it, see [Orders that need attention](docs/admin.md#orders-that-need-attention).
|
|
123
|
+
|
|
124
|
+
Every hook: `onOrderPaid`, `onOrderExpired`, `onOrderNeedsAttention`,
|
|
125
|
+
`onOrderPaymentFailed`, `onOrderShipped`, `onOrderRefunded`. The table under
|
|
126
|
+
"Shipping rates and order hooks" says when each fires. For a working
|
|
127
|
+
reference, see `apps/playground-e2e/tests/order-management.spec.ts`.
|
|
128
|
+
|
|
129
|
+
## Admin API and payments
|
|
130
|
+
|
|
131
|
+
- [Admin API](docs/admin.md): product and order management, refunds, and orders that need attention.
|
|
132
|
+
- [Payment gateways](docs/payments.md): the `PaymentGateway` contract and the manual Stripe test-mode purchase.
|
|
133
|
+
|
|
134
|
+
## Caching
|
|
135
|
+
|
|
136
|
+
The public catalog GETs send `Cache-Control: public, max-age=30,
|
|
137
|
+
stale-while-revalidate=300` and a strong `ETag`, and answer a matching
|
|
138
|
+
`If-None-Match` (including `W/` and comma lists) with an empty 304. Errors get
|
|
139
|
+
`Cache-Control: no-store`. Admin, checkout and order routes are untouched.
|
|
140
|
+
|
|
141
|
+
Pass `catalogCache: { cacheControl: '...' }` to `commercePlugin` to change the
|
|
142
|
+
header, or `cacheControl: false` to omit it (the `ETag` is still sent), for
|
|
143
|
+
hosts that manage caching at a CDN.
|
|
144
|
+
|
|
145
|
+
The `ETag` hashes the uncompressed body. Register `@fastify/compress` at the
|
|
146
|
+
app root before `commercePlugin`, so its `onSend` runs after the catalog's.
|
|
147
|
+
|
|
148
|
+
## Production checklist
|
|
149
|
+
|
|
150
|
+
- **Compression:** register `@fastify/compress` at the app root before
|
|
151
|
+
`commercePlugin` (JSON shrinks 70-80%). The kit doesn't wrap your app.
|
|
152
|
+
- **Catalog caching:** see [Caching](#caching); put a CDN in front of the
|
|
153
|
+
catalog GETs.
|
|
154
|
+
- **Images:** serve uploads with long immutable caching and lazy-loaded
|
|
155
|
+
`<img>`s (the storefront's default); keep image types out of compression.
|
|
156
|
+
- **Uploads on a CDN:** point `storage` at object storage behind a CDN rather
|
|
157
|
+
than the API process.
|
|
158
|
+
- **Webhooks:** forward the Stripe events (`checkout.session.completed`,
|
|
159
|
+
expiry and async failure) to `/orders/webhooks/stripe`; see
|
|
160
|
+
[Payment gateways](docs/payments.md).
|
|
161
|
+
Webhook raw-body parsing is request-side and unaffected by compression.
|
|
162
|
+
- **Retries:** refunds require an `Idempotency-Key`; reuse it when retrying so
|
|
163
|
+
a refund can't be issued twice.
|
|
164
|
+
- Admin routes: see [Admin API](docs/admin.md).
|
|
165
|
+
|
|
166
|
+
## Routes
|
|
167
|
+
|
|
168
|
+
Relative to the host's prefix:
|
|
169
|
+
|
|
170
|
+
| Route | Purpose |
|
|
171
|
+
| -------------------------------- | ---------------------------------------------------------- |
|
|
172
|
+
| `GET /catalog/products` | Active products (`?category=`, `?featured=true`) |
|
|
173
|
+
| `GET /catalog/products/:slug` | One product, or 404 `PRODUCT_NOT_FOUND` |
|
|
174
|
+
| `GET /catalog/categories` | Active product counts per category |
|
|
175
|
+
| `POST /checkout` | `{ items: [{ productId, quantity }] }` → `{ url }`, or 409 |
|
|
176
|
+
| `POST /orders/webhooks/stripe` | Stripe webhook (raw body, `stripe-signature` header) |
|
|
177
|
+
| `GET /orders/session/:sessionId` | `orderSummarySchema` for the success page |
|
|
178
|
+
|
|
179
|
+
## How checkout stays honest
|
|
180
|
+
|
|
181
|
+
- Prices come from the database. Anything else the client sends is ignored.
|
|
182
|
+
- `POST /checkout` locks the product rows (`SELECT … FOR UPDATE`), checks free
|
|
183
|
+
quantity (`quantity − active unexpired holds`), and records holds for 31
|
|
184
|
+
minutes, matching the Stripe session's `expires_at`. Two buyers racing for a
|
|
185
|
+
single piece get one 200 and one 409 `{ unavailableProductIds }`.
|
|
186
|
+
- The catalog reports `reserved` while every unit is held, and `quantity` is
|
|
187
|
+
the free quantity.
|
|
188
|
+
- `checkout.session.completed` marks the order paid and decrements stock.
|
|
189
|
+
`checkout.session.expired` releases the holds. Each event id is stored in
|
|
190
|
+
`commerce_stripe_events` in the same transaction, so replays are no-ops.
|
|
191
|
+
- A completion that arrives after the hold lapsed is committed only if the
|
|
192
|
+
stock is still free. If another buyer took it, quantity is left alone, the
|
|
193
|
+
order becomes `needs_attention`, and `onOrderNeedsAttention` fires instead
|
|
194
|
+
of `onOrderPaid`.
|
|
195
|
+
- A completed session with `payment_status: unpaid` (bank debits) leaves the
|
|
196
|
+
order `pending`, stores the buyer's details, and extends the hold to 14
|
|
197
|
+
days. `checkout.session.async_payment_succeeded` then settles it exactly
|
|
198
|
+
like a completion; `async_payment_failed` cancels the order and releases the
|
|
199
|
+
stock.
|
|
200
|
+
- Enable these webhook events in the Stripe dashboard:
|
|
201
|
+
`checkout.session.completed`, `checkout.session.expired`,
|
|
202
|
+
`checkout.session.async_payment_succeeded`,
|
|
203
|
+
`checkout.session.async_payment_failed`, `charge.refunded`.
|
|
204
|
+
- `charge.refunded` records refunds made outside the kit (Stripe dashboard,
|
|
205
|
+
support, disputes) as `source: 'gateway'` refunds and fires
|
|
206
|
+
`onOrderRefunded`. Refunds started through the admin API are matched by
|
|
207
|
+
their idempotency key and never recorded twice. Gateway refunds never
|
|
208
|
+
restock, because the kit can't know whether the goods came back.
|
|
209
|
+
- `startHoldSweeper` only tidies lapsed holds; free quantity already ignores
|
|
210
|
+
them, so the plugin is correct without it.
|