@develemit/commerce-server 0.3.0 → 0.6.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 +51 -8
- package/dist/index.d.ts +464 -2
- package/dist/index.js +1045 -144
- package/dist/index.js.map +1 -1
- package/docs/admin.md +30 -2
- package/docs/payments.md +29 -1
- package/migrations/0007_discounts.sql +40 -0
- package/migrations/meta/0007_snapshot.json +1028 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -2
package/docs/admin.md
CHANGED
|
@@ -9,8 +9,11 @@ management under `/admin`. Without it, no admin routes exist (404).
|
|
|
9
9
|
The kit owns no identity: check a session, token or header yourself.
|
|
10
10
|
- `storage` implements `FileStorage` (`put(key, stream, contentType)` and
|
|
11
11
|
`delete(key)`). Keys look like `products/<productId>/<uuid>.<ext>`.
|
|
12
|
-
`createMemoryStorage()` is for tests
|
|
13
|
-
`
|
|
12
|
+
`createMemoryStorage()` is for tests. `createDiskStorage({ dir, publicUrl })`
|
|
13
|
+
is a development-grade local disk store that rejects keys escaping `dir`
|
|
14
|
+
(`..`, absolute paths, encoded traversal, NUL bytes, symlinks out); serve its
|
|
15
|
+
files with `openStoredFile(dir, key)`, which returns `{ stream, contentType,
|
|
16
|
+
size }` or `null` (answer 404).
|
|
14
17
|
|
|
15
18
|
| Route | Purpose |
|
|
16
19
|
| ---------------------------------------- | ------------------------------------------------------ |
|
|
@@ -18,6 +21,7 @@ management under `/admin`. Without it, no admin routes exist (404).
|
|
|
18
21
|
| `GET/PATCH /admin/products/:id` | Read / edit; `{ sold: true }` sets quantity to 0 |
|
|
19
22
|
| `POST /admin/products/:id/images` | Multipart `file` (+ optional `alt`), 8MB max |
|
|
20
23
|
| `PATCH /admin/products/:id/images/order` | `{ imageIds }`, every image exactly once |
|
|
24
|
+
| `PATCH /admin/products/:id/images/:id` | `{ alt }` (trimmed, max 500); 404 for unknown image |
|
|
21
25
|
| `DELETE /admin/products/:id/images/:id` | Removes the row and the stored file |
|
|
22
26
|
| `GET/PUT /admin/shipping-rates` | `PUT` replaces the full set of rates |
|
|
23
27
|
|
|
@@ -86,3 +90,27 @@ the item. Either:
|
|
|
86
90
|
take the last unit. The order becomes `paid` and `onOrderPaid` fires, so the
|
|
87
91
|
host sends its normal confirmation.
|
|
88
92
|
- **Refund it:** `POST /admin/orders/:id/refunds`, see above.
|
|
93
|
+
|
|
94
|
+
## Admin discounts API
|
|
95
|
+
|
|
96
|
+
Registered under `/admin/discounts` only when both `admin` and checkout are
|
|
97
|
+
configured, behind the same `authorize` hook (401 otherwise).
|
|
98
|
+
|
|
99
|
+
| Route | Purpose |
|
|
100
|
+
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
101
|
+
| `GET /admin/discounts` | Newest first. `?status=active\|inactive\|scheduled\|expired` (computed from `active` and the dates), `?q=` (code prefix), `?cursor=`, `?limit=` (1-100, 25) |
|
|
102
|
+
| `POST /admin/discounts` | `DiscountInput`. 201 with the discount. A taken code: 409 `DISCOUNT_CODE_TAKEN` |
|
|
103
|
+
| `GET /admin/discounts/:id` | One discount |
|
|
104
|
+
| `PATCH /admin/discounts/:id` | `DiscountPatch`. Send `null` to clear `minSubtotalCents`, `startsAt`, `endsAt` or `maxRedemptions` |
|
|
105
|
+
|
|
106
|
+
Every discount carries `redeemedCount` (committed redemptions) and
|
|
107
|
+
`reservedCount` (reserved, awaiting payment). The list returns
|
|
108
|
+
`{ items, nextCursor }`; treat the cursor as opaque.
|
|
109
|
+
|
|
110
|
+
- Codes are trimmed and uppercased. Once a redemption exists in any status,
|
|
111
|
+
`code`, `type`, `percentOff`, `amountOffCents` and `currency` cannot change
|
|
112
|
+
(409 `DISCOUNT_IN_USE`); dates, `active`, `minSubtotalCents` and
|
|
113
|
+
`maxRedemptions` still can.
|
|
114
|
+
- `maxRedemptions` cannot go below reserved plus committed redemptions (400
|
|
115
|
+
`MAX_REDEMPTIONS_BELOW_USAGE`).
|
|
116
|
+
- There is no delete. Deactivate with `{ "active": false }`.
|
package/docs/payments.md
CHANGED
|
@@ -10,13 +10,41 @@ Two ship with the package:
|
|
|
10
10
|
- `createStripeGateway({ secretKey, webhookSecret })` loads `stripe` lazily, so
|
|
11
11
|
it's an optional peer. Refunds look up the session's PaymentIntent and call
|
|
12
12
|
`refunds.create` with the idempotency key.
|
|
13
|
-
- `createFakeGateway()` for tests
|
|
13
|
+
- `createFakeGateway()` for tests and local development. By default its
|
|
14
|
+
session URL is the success URL (see [Local checkout](#local-checkout-without-stripe)
|
|
15
|
+
for a click-through flow), and
|
|
14
16
|
`completed(sessionId)` / `expired(sessionId)` / `asyncPaid(sessionId)` /
|
|
15
17
|
`asyncFailed(sessionId)` return signed webhook bodies to
|
|
16
18
|
post to the webhook route. `refunds` lists the refunds it made (one per
|
|
17
19
|
key), `refundCalls` every call, and `failNextRefund()` makes the next one
|
|
18
20
|
throw.
|
|
19
21
|
|
|
22
|
+
## Local checkout without Stripe
|
|
23
|
+
|
|
24
|
+
Pass `devCheckout: true` (or `{ apiUrl: 'http://localhost:3301' }` when the
|
|
25
|
+
browser reaches the api on another origin) to `commercePlugin` while using the
|
|
26
|
+
fake gateway. Checkout then returns a session URL pointing at
|
|
27
|
+
`GET <prefix>/checkout/dev/:sessionId/complete`, which applies a paid
|
|
28
|
+
`checkout.completed` event through the same path the webhook uses (holds and
|
|
29
|
+
discount redemptions commit, `onOrderPaid` runs) and answers 302 to
|
|
30
|
+
`urls.success(sessionId)`. `?outcome=expired` releases the order instead and
|
|
31
|
+
redirects to `urls.cancel`. The route is only registered for the fake gateway,
|
|
32
|
+
only knows sessions created since the process started (404 otherwise), and
|
|
33
|
+
`commercePlugin` throws at registration when `NODE_ENV=production`. Gate the
|
|
34
|
+
option on `NODE_ENV !== 'production'` so one config works in every
|
|
35
|
+
environment.
|
|
36
|
+
|
|
37
|
+
## Discounts
|
|
38
|
+
|
|
39
|
+
A discount code is an amount off the merchandise subtotal, never shipping. The
|
|
40
|
+
server re-evaluates the code at checkout and passes the gateway
|
|
41
|
+
`discount: { code, amountCents }` on `createCheckoutSession`. A custom gateway
|
|
42
|
+
must apply it (for example as a one-off coupon or a negative line), or the
|
|
43
|
+
buyer is charged the undiscounted price while the order records the discount.
|
|
44
|
+
`createStripeGateway` creates a one-off coupon (`amount_off`,
|
|
45
|
+
`duration: 'once'`, `max_redemptions: 1`) and passes it in `discounts`.
|
|
46
|
+
`createFakeGateway` records the discount on `sessions`.
|
|
47
|
+
|
|
20
48
|
## Manual Stripe test-mode purchase
|
|
21
49
|
|
|
22
50
|
Uses the playground apps in this repo and a Stripe account in test mode.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
CREATE TABLE "commerce_discount_redemptions" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"discount_id" uuid NOT NULL,
|
|
4
|
+
"order_id" uuid NOT NULL,
|
|
5
|
+
"status" text DEFAULT 'reserved' NOT NULL,
|
|
6
|
+
"amount_cents" integer NOT NULL,
|
|
7
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
8
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
9
|
+
CONSTRAINT "commerce_discount_redemptions_order_id_unique" UNIQUE("order_id"),
|
|
10
|
+
CONSTRAINT "commerce_discount_redemptions_amount_check" CHECK ("commerce_discount_redemptions"."amount_cents" > 0),
|
|
11
|
+
CONSTRAINT "commerce_discount_redemptions_status_check" CHECK ("commerce_discount_redemptions"."status" in ('reserved', 'committed', 'released'))
|
|
12
|
+
);
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
CREATE TABLE "commerce_discounts" (
|
|
15
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
16
|
+
"code" text NOT NULL,
|
|
17
|
+
"type" text NOT NULL,
|
|
18
|
+
"percent_off" integer,
|
|
19
|
+
"amount_off_cents" integer,
|
|
20
|
+
"currency" text,
|
|
21
|
+
"min_subtotal_cents" integer,
|
|
22
|
+
"starts_at" timestamp with time zone,
|
|
23
|
+
"ends_at" timestamp with time zone,
|
|
24
|
+
"max_redemptions" integer,
|
|
25
|
+
"active" boolean DEFAULT true NOT NULL,
|
|
26
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
27
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
28
|
+
CONSTRAINT "commerce_discounts_code_unique" UNIQUE("code"),
|
|
29
|
+
CONSTRAINT "commerce_discounts_code_check" CHECK ("commerce_discounts"."code" = upper("commerce_discounts"."code")),
|
|
30
|
+
CONSTRAINT "commerce_discounts_percent_check" CHECK ("commerce_discounts"."percent_off" is null or ("commerce_discounts"."percent_off" between 1 and 100)),
|
|
31
|
+
CONSTRAINT "commerce_discounts_amount_check" CHECK ("commerce_discounts"."amount_off_cents" is null or "commerce_discounts"."amount_off_cents" > 0),
|
|
32
|
+
CONSTRAINT "commerce_discounts_max_redemptions_check" CHECK ("commerce_discounts"."max_redemptions" is null or "commerce_discounts"."max_redemptions" > 0),
|
|
33
|
+
CONSTRAINT "commerce_discounts_type_fields_check" CHECK (("commerce_discounts"."type" = 'percent' and "commerce_discounts"."percent_off" is not null and "commerce_discounts"."amount_off_cents" is null and "commerce_discounts"."currency" is null) or ("commerce_discounts"."type" = 'fixed' and "commerce_discounts"."percent_off" is null and "commerce_discounts"."amount_off_cents" is not null and "commerce_discounts"."currency" is not null))
|
|
34
|
+
);
|
|
35
|
+
--> statement-breakpoint
|
|
36
|
+
ALTER TABLE "commerce_orders" ADD COLUMN "discount_cents" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
|
37
|
+
ALTER TABLE "commerce_orders" ADD COLUMN "discount_code" text;--> statement-breakpoint
|
|
38
|
+
ALTER TABLE "commerce_discount_redemptions" ADD CONSTRAINT "commerce_discount_redemptions_discount_id_commerce_discounts_id_fk" FOREIGN KEY ("discount_id") REFERENCES "public"."commerce_discounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
39
|
+
ALTER TABLE "commerce_discount_redemptions" ADD CONSTRAINT "commerce_discount_redemptions_order_id_commerce_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."commerce_orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
40
|
+
CREATE INDEX "commerce_discount_redemptions_discount_status_idx" ON "commerce_discount_redemptions" USING btree ("discount_id","status");
|