@develemit/commerce-server 0.0.0-stage → 0.2.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 CHANGED
@@ -1,3 +1,178 @@
1
- # Temporary Holding Version
1
+ # @develemit/commerce-server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ## Routes
135
+
136
+ Relative to the host's prefix:
137
+
138
+ | Route | Purpose |
139
+ | -------------------------------- | ---------------------------------------------------------- |
140
+ | `GET /catalog/products` | Active products (`?category=`, `?featured=true`) |
141
+ | `GET /catalog/products/:slug` | One product, or 404 `PRODUCT_NOT_FOUND` |
142
+ | `GET /catalog/categories` | Active product counts per category |
143
+ | `POST /checkout` | `{ items: [{ productId, quantity }] }` → `{ url }`, or 409 |
144
+ | `POST /orders/webhooks/stripe` | Stripe webhook (raw body, `stripe-signature` header) |
145
+ | `GET /orders/session/:sessionId` | `orderSummarySchema` for the success page |
146
+
147
+ ## How checkout stays honest
148
+
149
+ - Prices come from the database. Anything else the client sends is ignored.
150
+ - `POST /checkout` locks the product rows (`SELECT … FOR UPDATE`), checks free
151
+ quantity (`quantity − active unexpired holds`), and records holds for 31
152
+ minutes, matching the Stripe session's `expires_at`. Two buyers racing for a
153
+ single piece get one 200 and one 409 `{ unavailableProductIds }`.
154
+ - The catalog reports `reserved` while every unit is held, and `quantity` is
155
+ the free quantity.
156
+ - `checkout.session.completed` marks the order paid and decrements stock.
157
+ `checkout.session.expired` releases the holds. Each event id is stored in
158
+ `commerce_stripe_events` in the same transaction, so replays are no-ops.
159
+ - A completion that arrives after the hold lapsed is committed only if the
160
+ stock is still free. If another buyer took it, quantity is left alone, the
161
+ order becomes `needs_attention`, and `onOrderNeedsAttention` fires instead
162
+ of `onOrderPaid`.
163
+ - A completed session with `payment_status: unpaid` (bank debits) leaves the
164
+ order `pending`, stores the buyer's details, and extends the hold to 14
165
+ days. `checkout.session.async_payment_succeeded` then settles it exactly
166
+ like a completion; `async_payment_failed` cancels the order and releases the
167
+ stock.
168
+ - Enable these webhook events in the Stripe dashboard:
169
+ `checkout.session.completed`, `checkout.session.expired`,
170
+ `checkout.session.async_payment_succeeded`,
171
+ `checkout.session.async_payment_failed`, `charge.refunded`.
172
+ - `charge.refunded` records refunds made outside the kit (Stripe dashboard,
173
+ support, disputes) as `source: 'gateway'` refunds and fires
174
+ `onOrderRefunded`. Refunds started through the admin API are matched by
175
+ their idempotency key and never recorded twice. Gateway refunds never
176
+ restock, because the kit can't know whether the goods came back.
177
+ - `startHoldSweeper` only tidies lapsed holds; free quantity already ignores
178
+ them, so the plugin is correct without it.