@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/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; a local-disk example lives in
13
- `apps/playground-api/src/storage.ts`.
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. Its session URL is the success URL, and
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");