digiwin_dsp 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a72275c796b57bd858c4d7c5a67b7074e2738985ae92a9011836c0db9383321
4
- data.tar.gz: 78db9041211bbc64cf7b9cb9cbfccb5f1dbef00efbfc4d5aa7b7a9e44d458f63
3
+ metadata.gz: 8b74fbc1739d80f99a563f002be9ab06212eb0ffce531ddf9144c9c8ceb28b34
4
+ data.tar.gz: 363e1faecee60d9a4c8f5cfe03b00bf2febd35f015f0034451e1c9c9e191308a
5
5
  SHA512:
6
- metadata.gz: 82cc7dbb4ca9194b851ee1bdcd853fc6c084d13ae41d6d1c38ebefec8fba80be722bf40e77e332db7ef1e821aec8d8522358740bcd4531e21744fcb66844f9fa
7
- data.tar.gz: ba67eb5115c8aa4d978b71f52c1ac063eb2643bdaccdfa30cb9ed3f3dfd1022b3378497c04cfc468d317a211453d983751ce2cfba635eeceb6d9e8c98044655c
6
+ metadata.gz: b14060dbe2c6d65890ffb925c1ee9f499a7870db0c4eb301646eaf0ea2907869e0e331d8ff14b76e1a6bd474a2de5bf75f97d9bdf5073d856a5c55b6753dd225
7
+ data.tar.gz: 1f1f7ab22cd0e077e455ce7cc6451442e5285ccf7e1c94a1abcb0c6e284e3fc86f94cf879170b4ddd2cc3d747ca532ba64a6082bf89be972aacadef23df33e7c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,52 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-06-12
10
+
11
+ Ergonomics + cleanup release. One breaking change (dead config removal).
12
+
13
+ ### BREAKING
14
+
15
+ - **`Configuration#api_secret` removed** (accessor, `DIGIWIN_DSP_API_SECRET` ENV read, README row, `.env.local.example` line). Reserved since v0.1.0 for "future HMAC signing" — DSPOOFFICIAL100 confirmed DSP doesn't sign webhooks, no gem code ever read it, and advertising it implied a security mechanism that didn't exist. **Migration:** delete `c.api_secret = ...` from your configure block (it would now raise `NoMethodError` at boot); a lingering ENV var is harmless.
16
+
17
+ ### Added
18
+
19
+ - **`DigiwinDsp::Enums`** — DSP code tables as Ruby constants so callers stop hardcoding `"9104"`:
20
+ `OrderStatus`, `PayType` (15), `ShippingType` (9), `InvoiceStatus` (5), `InvoiceType` (10), `CarrierType` (5), `IsPay`, plus inbound-webhook `UpdateMode` and `DistributorCode`. Each has a frozen `ALL` array for caller-side validations. No forced client-side validation — DSP still rejects unknown codes via `WrongStatus:` → `ValidationError`.
21
+ - **Unknown-envelope warning.** If a 2xx Hash body matches neither known envelope shape (`Status/Message` nor `std_data.execution`), `Client` now `logger.warn`s before passing it through — so if DSP ships a third envelope, failures can't silently read as success.
22
+ - **Multi-line `last_record` live-verified.** A 2-line order with `"N"` on the non-final line was accepted by UAT (2026-06-12). The YAML spec's "blank means not-last" alternative conflicts with the gem's required-field check; the documented `"N"` convention is now the verified path.
23
+
24
+ ### Changed
25
+
26
+ - `WebhookSubscription::ACTIONS` now derives from `Webhooks::ACTION_REGISTRY.keys` — what you can subscribe to is exactly what the gem can parse (single source of truth; previously two parallel lists that could drift).
27
+
28
+ ### Docs
29
+
30
+ - README gains a **Troubleshooting / FAQ** section (HTTP-200-failure gotcha, `序號驗證失敗`, `SalesNotCreate` timing, `Shipped:` permanence, `WrongStatus` self-correction, dedupe-on-`form_no` surprises, webhook-delivery checklist, ERP invoice-visibility caveat).
31
+ - README documents the **built-in Faraday retry** (up to 4 attempts, exponential backoff + jitter) and how it stacks with ActiveJob/Sidekiq retries — worst-case latency math included.
32
+ - README flags DSPOOFFICIAL004's 個案 caveat: invoice data is only visible inside the ERP after per-customer Digiwin customization.
33
+
34
+ ## [0.3.1] - 2026-06-12
35
+
36
+ Hardening patch from a full gem + docs review. All fixes grounded in the vendor YAML specs.
37
+
38
+ ### Fixed
39
+
40
+ - **TLS failures now raise `DigiwinDsp::NetworkError`.** `Faraday::SSLError` sits directly under `Faraday::Error` (not `ConnectionFailed`), so certificate problems previously leaked as raw Faraday exceptions that `rescue DigiwinDsp::Error` could not catch.
41
+ - **Three more DSP failure messages classify into typed exceptions** instead of falling through to generic `Error`:
42
+ - `Shipped:訂單已出貨,不可取消` (DSPOOFFICIAL002) → `ValidationError` — permanent; the order left the warehouse
43
+ - `Processing:新增訂單處理中,不可取消` (DSPOOFFICIAL002) → `RateLimitError` — retryable once ERP processes the add
44
+ - `SalesNotCreate:銷貨單未成立` (DSPOOFFICIAL004) → `RateLimitError` — retryable once ERP converts the sales doc
45
+ Sidekiq/ActiveJob retry strategies can now distinguish "don't retry" from "retry later" on cancel and invoice flows.
46
+ - **`Webhooks::Event.parse_json` / `.extract_request` are now private class methods.** They were internal helpers accidentally exposed on all three event classes.
47
+ - **`WebhookSubscription` rejects non-`https://` callback addresses** at registration time. DSPOOFFICIAL100 mandates HTTPS callbacks, and with no HMAC signing a plaintext callback would be indefensible. (Previously the README claimed this was enforced when it wasn't.)
48
+
49
+ ### Docs
50
+
51
+ - `dsp-api-spec.md`: corrected the e-invoice carrier field name — `Resources::Invoice` sends **`carrier_type`** (DSPOOFFICIAL004:164), not `carrier_code`. `carrier_code` is only on `Resources::Order` and the inbound webhook. Optional fields aren't validated client-side, so the wrong name silently drops carrier data.
52
+ - `dsp-api-spec.md`: failure-message table now covers 002 (cancel) and 004 (invoice) sources, not just 001.
53
+ - README: webhook security bullet now describes the actual https enforcement.
54
+
9
55
  ## [0.3.0] - 2026-05-28
10
56
 
11
57
  DSP webhook support (DSPOOFFICIAL100). Lets a Rails app register a callback URL with DSP, then receive and parse the three documented ERP-originated push events: inventory updates, shipping/logistics status, and invoice issuance.
@@ -205,7 +251,9 @@ Initial release. Covers the four Self-hosted Website Module (自有官網模組)
205
251
  - The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
206
252
  - Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
207
253
 
208
- [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.0...HEAD
254
+ [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.0...HEAD
255
+ [0.4.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.1...v0.4.0
256
+ [0.3.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.0...v0.3.1
209
257
  [0.3.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.4...v0.3.0
210
258
  [0.2.4]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.3...v0.2.4
211
259
  [0.2.3]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.2...v0.2.3
data/README.md CHANGED
@@ -51,7 +51,6 @@ Every setting also falls back to an ENV var:
51
51
  | Setting | ENV var | Default | Notes |
52
52
  |---|---|---|---|
53
53
  | `api_key` | `DIGIWIN_DSP_API_KEY` | _(required)_ | sent as `DSP-api-key` header |
54
- | `api_secret` | `DIGIWIN_DSP_API_SECRET` | `nil` | reserved for future HMAC signing; unused today |
55
54
  | `platform_id` | `DIGIWIN_DSP_PLATFORM_ID` | `nil` | sent **per-record** in `request_detail.platform_id` (not in auth headers) |
56
55
  | `environment` | `DIGIWIN_DSP_ENV` | `:sandbox` | `:sandbox` (UAT) or `:production` |
57
56
  | `base_url` | `DIGIWIN_DSP_BASE_URL` | resolved from `environment` | must be `https://` and have a host in `allowed_hosts` |
@@ -119,7 +118,7 @@ response_detail = DigiwinDsp::Resources::Order.create(record)
119
118
  # => [{ "form_no" => "WEB202605200001", ... }]
120
119
  ```
121
120
 
122
- Multi-line orders: pass an array. Each element must carry the order-level fields plus its own line fields. Set `"last_record" => "Y"` on the final element and `"N"` on the rest:
121
+ Multi-line orders: pass an array. Each element must carry the order-level fields plus its own line fields. Set `"last_record" => "Y"` on the final element and `"N"` on the rest (live-verified against UAT 2026-06-12; the YAML spec says blank means "not last", but the gem's required-field check rejects blanks and DSP accepts the explicit `"N"`):
123
122
 
124
123
  ```ruby
125
124
  records = [
@@ -144,6 +143,8 @@ Each has its own required-field set (8 / 11 / 19 fields respectively). Inspect `
144
143
  DigiwinDsp::Serializers::CancellationSerializer::REQUIRED_FIELDS
145
144
  ```
146
145
 
146
+ > ⚠️ **Invoice sync requires ERP-side customization.** Per DSPOOFFICIAL004's spec note (個案), DSP accepts your invoice data unconditionally, but it only becomes *visible inside the ERP* after Digiwin performs per-customer integration work. If invoices appear to sync successfully but the ERP team can't see them, this is why — confirm the customization with your Digiwin contact before debugging your own code.
147
+
147
148
  ### `order_status` enum
148
149
 
149
150
  Each endpoint requires a specific `order_status` value inside `request_detail`. DSP rejects others with `WrongStatus:order_status錯誤,請固定給N(...)`. The OpenAPI examples don't document this — verified live against UAT 2026-05-21:
@@ -213,12 +214,26 @@ end
213
214
  ```
214
215
 
215
216
  > ⚠️ **DSP does NOT sign inbound webhooks.** There is no HMAC header. Defend the callback endpoint with:
216
- > - HTTPS-only (the gem's `address` validation requires this implicitly via `allowed_hosts` if you register through it)
217
+ > - HTTPS-only `WebhookSubscription` rejects non-`https://` addresses at registration time (DSP's own spec mandates HTTPS callbacks)
217
218
  > - An unguessable URL path (treat it as a secret)
218
219
  > - An IP allowlist for DSP's egress range if your network team can get one
219
220
  > - Replying `200 OK` within 30 seconds (DSP will retry and may eventually block your endpoint if too many calls fail)
220
221
  > - **Idempotency by `form_no` / `invoice_number` / etc. on your side** — DSP may retry the same event
221
222
 
223
+ ### Built-in retry (read before stacking job retries)
224
+
225
+ Every `Client#post` already retries transparently inside Faraday:
226
+
227
+ | Setting | Value |
228
+ |---|---|
229
+ | Attempts | up to 4 (1 original + max 3 retries) |
230
+ | Triggers | HTTP 429, 500, 502, 503, 504, connection failures |
231
+ | Backoff | exponential — ~0.5s, ~1s, ~2s between attempts, ±50% jitter |
232
+
233
+ So one `Resources::Order.create` call can take up to ~`4 × timeout + 3.5s` in the worst case (default `timeout` 10s → ~44s). **Size your job timeouts and queue latency budgets accordingly** — if you also add `retry_on` in ActiveJob/Sidekiq (recommended for `RateLimitError`, which DSP signals via the envelope and the gem does *not* retry internally), the two layers multiply.
234
+
235
+ The built-in retry covers transport-level blips; envelope-level "retry later" signals (`Processing:資料處理中`, `SalesNotCreate:` etc. → `RateLimitError`) are deliberately left to your job layer, where you control scheduling.
236
+
222
237
  ### Background jobs
223
238
 
224
239
  The gem is synchronous on purpose. Wrap calls in your own job runner:
@@ -265,6 +280,32 @@ rescue DigiwinDsp::RateLimitError, DigiwinDsp::ServerError
265
280
  end
266
281
  ```
267
282
 
283
+ ## Troubleshooting / FAQ
284
+
285
+ **"My request succeeded with HTTP 200 but raised an exception?"**
286
+ That's DSP's design — application failures come back as HTTP 200 with `Status:"Failure"` in the body. The gem parses the envelope and raises the matching typed exception. Trust the exception, not the HTTP status.
287
+
288
+ **`AuthenticationError: DSP 序號驗證失敗`**
289
+ Your `DSP-api-key` is wrong, expired, or for the other environment (UAT keys don't work on production and vice versa). Note this arrives as HTTP 200, not 401.
290
+
291
+ **`RateLimitError: ...SalesNotCreate:銷貨單未成立`** (invoice sync)
292
+ The ERP hasn't converted the order into a sales document yet — this is a timing issue, not a bug. Retry later (the exception type is retryable by design). If it persists for hours, ask your Digiwin contact whether order conversion is running.
293
+
294
+ **`ValidationError: ...Shipped:訂單已出貨,不可取消`** (cancel)
295
+ Permanent — the order left the warehouse. Don't retry; surface to your support flow instead.
296
+
297
+ **`ValidationError: ...WrongStatus:order_status錯誤,請固定給N(...)`**
298
+ You sent the wrong `order_status` for that endpoint. DSP's message tells you the expected value; or just use `DigiwinDsp::Enums::OrderStatus` constants.
299
+
300
+ **`DuplicateRequestError` on a brand-new order**
301
+ DSP dedupes on `form_no + platform_id` forever — including orders created in earlier tests. Generate unique `form_no` values per environment.
302
+
303
+ **Inventory webhook never arrives**
304
+ Webhook delivery requires (1) a successful `WebhookSubscription.create` for that exact `action`, (2) an HTTPS endpoint answering `200` within 30s, and (3) the ERP actually emitting the event. Check all three, in that order.
305
+
306
+ **Invoices sync but the ERP team can't see them**
307
+ See the invoice caveat above — DSPOOFFICIAL004 requires per-customer ERP customization (個案) before invoice data is visible in the ERP.
308
+
268
309
  ## Custom `digi_header`
269
310
 
270
311
  By default the gem **omits `digi_header`** from the request body (it's only required for certain custom Digiwin integrations). If your DSP setup expects one, pass it through:
@@ -33,6 +33,9 @@ module DigiwinDsp
33
33
  [/Duplicated:/, DuplicateRequestError], # order already exists
34
34
  [/Processing:資料處理中/, RateLimitError], # transient; retry later
35
35
  [/Processing:取消訂單處理中/, ValidationError], # cancel in flight
36
+ [/Processing:新增訂單處理中/, RateLimitError], # add in flight; cancel retryable after ERP processes (002)
37
+ [/Shipped:/, ValidationError], # already shipped — cancel permanently impossible (002)
38
+ [/SalesNotCreate:/, RateLimitError], # ERP hasn't converted sales doc yet; invoice retryable (004)
36
39
  [/WrongStatus:/, ValidationError], # bad payload
37
40
  [/系統異常:/, ServerError] # DSP internal error
38
41
  ].freeze
@@ -52,7 +55,10 @@ module DigiwinDsp
52
55
  req.body = body
53
56
  end
54
57
  handle_response(response)
55
- rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
58
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
59
+ # SSLError sits directly under Faraday::Error (not ConnectionFailed),
60
+ # so it needs an explicit entry — otherwise TLS failures leak as raw
61
+ # Faraday exceptions that `rescue DigiwinDsp::Error` won't catch.
56
62
  raise NetworkError, e.message
57
63
  end
58
64
 
@@ -126,11 +132,25 @@ module DigiwinDsp
126
132
  return nil if exec["code"].to_s == "0"
127
133
 
128
134
  { message: exec["description"].to_s, code: exec["code"] }
129
- elsif body["Status"].to_s.casecmp("failure").zero?
135
+ elsif body.key?("Status")
136
+ return nil unless body["Status"].to_s.casecmp("failure").zero?
137
+
130
138
  { message: body["Message"].to_s, code: body["Status"] }
139
+ else
140
+ warn_unknown_envelope(body)
141
+ nil
131
142
  end
132
143
  end
133
144
 
145
+ # Neither known envelope. If DSP ships a third shape, failures would
146
+ # otherwise pass through as "success" silently — leave a trace.
147
+ def warn_unknown_envelope(body)
148
+ @configuration.logger.warn(
149
+ "digiwin_dsp: response matched no known envelope shape " \
150
+ "(keys: #{body.keys.inspect}); passing body through unchanged"
151
+ )
152
+ end
153
+
134
154
  def classify_envelope_failure(message, code:, body:)
135
155
  klass = ENVELOPE_FAILURE_MAP.find { |regex, _| regex.match?(message) }&.last || Error
136
156
  klass.new(message, code: code, dsp_message: message, request_id: body["request_id"], http_status: 200)
@@ -21,13 +21,12 @@ module DigiwinDsp
21
21
  production: "https://digiwindsp.digiwin.com/DSP/api/webhook"
22
22
  }.freeze
23
23
 
24
- attr_accessor :api_key, :api_secret, :platform_id, :environment, :logger,
24
+ attr_accessor :api_key, :platform_id, :environment, :logger,
25
25
  :timeout, :open_timeout, :allowed_hosts
26
26
  attr_writer :base_url, :webhook_base_url
27
27
 
28
28
  def initialize
29
29
  @api_key = ENV["DIGIWIN_DSP_API_KEY"]
30
- @api_secret = ENV["DIGIWIN_DSP_API_SECRET"]
31
30
  @platform_id = ENV["DIGIWIN_DSP_PLATFORM_ID"]
32
31
  @environment = ENV.fetch("DIGIWIN_DSP_ENV") { "sandbox" }.to_sym
33
32
  @base_url = ENV["DIGIWIN_DSP_BASE_URL"]
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigiwinDsp
4
+ # DSP wire-format code tables as Ruby constants, so callers write
5
+ # `Enums::PayType::CREDIT_CARD` instead of a bare "9104".
6
+ #
7
+ # Source of truth: docs/dsp-specs/*.yaml (see docs/dsp-api-spec.md for the
8
+ # readable digest). The gem deliberately does NOT validate these values
9
+ # client-side — DSP rejects unknown codes with `WrongStatus:` →
10
+ # `ValidationError`. The `ALL` arrays exist for caller-side checks
11
+ # (e.g. dropdowns, model validations) if you want them.
12
+ module Enums
13
+ # request_detail.order_status — fixed per endpoint (live-verified).
14
+ module OrderStatus
15
+ CANCEL = "2" # Resources::Cancellation
16
+ NEW_ORDER = "3" # Resources::Order
17
+ INVOICE = "5" # Resources::Invoice
18
+ RETURN = "7" # Resources::Return
19
+
20
+ ALL = [CANCEL, NEW_ORDER, INVOICE, RETURN].freeze
21
+ end
22
+
23
+ # request_detail.pay_type on Resources::Order (DSPOOFFICIAL001:163-179).
24
+ module PayType
25
+ OTHER = "9100" # 其他收款方式
26
+ JKO_PAY = "9101" # 街口支付
27
+ CVS_COD = "9102" # 超商取貨付款
28
+ GOOGLE_PAY = "9103"
29
+ CREDIT_CARD = "9104" # 信用卡一次付款
30
+ CASH_ON_DELIVERY = "9105" # 貨到付款
31
+ AFTEE = "9106" # AFTEE 先享後付
32
+ APPLE_PAY = "9107"
33
+ ATM = "9108" # ATM 付款
34
+ CREDIT_CARD_INSTALLMENT = "9109" # 信用卡分期付款
35
+ EASY_WALLET = "9110" # 悠遊付
36
+ LINE_PAY = "9111"
37
+ PAYPAL_EXPRESS = "9112"
38
+ FREE_CHECKOUT = "9113" # 免費結帳
39
+ CVS_PAYMENT_CODE = "9114" # 超商代碼繳費
40
+
41
+ ALL = [OTHER, JKO_PAY, CVS_COD, GOOGLE_PAY, CREDIT_CARD, CASH_ON_DELIVERY,
42
+ AFTEE, APPLE_PAY, ATM, CREDIT_CARD_INSTALLMENT, EASY_WALLET,
43
+ LINE_PAY, PAYPAL_EXPRESS, FREE_CHECKOUT, CVS_PAYMENT_CODE].freeze
44
+ end
45
+
46
+ # request_detail.shipping_type on Resources::Order (DSPOOFFICIAL001:186-200).
47
+ module ShippingType
48
+ OTHER = "9100" # 其他取貨方式
49
+ INTERNATIONAL = "9101" # 海外宅配
50
+ HOME_DELIVERY_COD = "9102" # 宅配貨到付款
51
+ STORE_PICKUP_PAID = "9103" # 付款後門市自取
52
+ HOME_DELIVERY_PAID = "9104" # 宅配(含離島)已付款只取貨
53
+ CVS_PICKUP_PAID = "9105" # 付款後超商取貨
54
+ CVS_PICKUP_COD = "9106" # 超商取貨付款
55
+ HOME_DELIVERY_CASH = "9107" # 宅配(含離島)貨到付現
56
+ HOME_DELIVERY_CARD = "9108" # 宅配(含離島)貨到刷卡
57
+
58
+ ALL = [OTHER, INTERNATIONAL, HOME_DELIVERY_COD, STORE_PICKUP_PAID,
59
+ HOME_DELIVERY_PAID, CVS_PICKUP_PAID, CVS_PICKUP_COD,
60
+ HOME_DELIVERY_CASH, HOME_DELIVERY_CARD].freeze
61
+ end
62
+
63
+ # request_detail.invoice_status on Resources::Invoice (DSPOOFFICIAL004:110-122).
64
+ module InvoiceStatus
65
+ ISSUED = "1" # 開立
66
+ VOIDED = "2" # 作廢
67
+ ALLOWANCE = "3" # 折讓
68
+ CANCELLED = "4" # 註銷
69
+ ALLOWANCE_VOIDED = "5" # 折讓作廢
70
+
71
+ ALL = [ISSUED, VOIDED, ALLOWANCE, CANCELLED, ALLOWANCE_VOIDED].freeze
72
+ end
73
+
74
+ # request_detail.invoice_type on Resources::Invoice (DSPOOFFICIAL004:123-140).
75
+ module InvoiceType
76
+ DUPLICATE = "1" # 二聯式
77
+ TRIPLICATE = "2" # 三聯式
78
+ DUPLICATE_REGISTER = "3" # 二聯式收銀機發票
79
+ TRIPLICATE_REGISTER = "4" # 三聯式收銀機發票
80
+ COMPUTER = "5" # 電子計算機發票
81
+ EXEMPT = "6" # 免用統一發票
82
+ E_INVOICE = "7" # 電子發票 — modern default in Taiwan
83
+ CHINA_VAT_SPECIAL = "A" # 增值稅專用發票
84
+ CHINA_GENERAL = "B" # 普通發票
85
+ CHINA_EXEMPT = "C" # 免用發票
86
+
87
+ ALL = [DUPLICATE, TRIPLICATE, DUPLICATE_REGISTER, TRIPLICATE_REGISTER,
88
+ COMPUTER, EXEMPT, E_INVOICE, CHINA_VAT_SPECIAL, CHINA_GENERAL,
89
+ CHINA_EXEMPT].freeze
90
+ end
91
+
92
+ # E-invoice carrier codes (DSPOOFFICIAL004:167-176). ⚠️ Field name varies:
93
+ # `carrier_type` on Resources::Invoice, `carrier_code` on Resources::Order
94
+ # and the inbound Webhooks::InvoiceUpdate payload.
95
+ module CarrierType
96
+ IPASS = "1H0001" # 一卡通
97
+ EASYCARD = "1K0001" # 悠遊卡
98
+ ICASH = "2G0001"
99
+ MOBILE_BARCODE = "3J0002" # 手機條碼 — most common
100
+ CITIZEN_CERT = "CQ0001" # 自然人憑證
101
+
102
+ ALL = [IPASS, EASYCARD, ICASH, MOBILE_BARCODE, CITIZEN_CERT].freeze
103
+ end
104
+
105
+ # request_detail.is_pay on Resources::Invoice (DSPOOFFICIAL004:189-195).
106
+ module IsPay
107
+ UNPAID = "0"
108
+ PAID = "1"
109
+
110
+ ALL = [UNPAID, PAID].freeze
111
+ end
112
+
113
+ # spec_list[].update_mode on inbound Webhooks::InventoryUpdate
114
+ # (DSPOOFFICIAL100). "adjust" requires per-customer ERP customization.
115
+ module UpdateMode
116
+ TOTAL = "total" # stock is the new total
117
+ ADJUST = "adjust" # stock is a delta (can be negative)
118
+
119
+ ALL = [TOTAL, ADJUST].freeze
120
+ end
121
+
122
+ # distributor_code on inbound Webhooks::LogisticsUpdate (DSPOOFFICIAL100).
123
+ # Spec lists only these two; handle unknown carriers defensively.
124
+ module DistributorCode
125
+ HCT = "HCT" # 新竹倉儲
126
+ CAT = "CAT" # 統一速達(黑貓)
127
+
128
+ ALL = [HCT, CAT].freeze
129
+ end
130
+ end
131
+ end
@@ -12,11 +12,9 @@ module DigiwinDsp
12
12
  class WebhookSubscription
13
13
  PATH = "/v1/webhook"
14
14
  DEFAULT_PROD = "OFFICIALWEBSITE"
15
- ACTIONS = %w[
16
- product/inventory_update
17
- wms/logistics/package/update
18
- invoice/update
19
- ].freeze
15
+ # Single source of truth lives in Webhooks::ACTION_REGISTRY — what you
16
+ # can subscribe to is exactly what the gem can parse.
17
+ ACTIONS = Webhooks::ACTION_REGISTRY.keys.freeze
20
18
  ADDRESS_MAX_LENGTH = 500
21
19
 
22
20
  def self.create(action:, address:, platform_id: nil, prod: DEFAULT_PROD)
@@ -43,6 +41,11 @@ module DigiwinDsp
43
41
  "action must be one of #{ACTIONS.inspect} (got #{action.inspect})"
44
42
  end
45
43
  raise DigiwinDsp::ValidationError, "address is required" if address.nil? || address.to_s.empty?
44
+
45
+ # DSPOOFFICIAL100 mandates HTTPS for the callback ("必須在 30 秒內以
46
+ # HTTPS 回應") — and with no HMAC signing, a plaintext callback URL
47
+ # would be indefensible anyway.
48
+ raise DigiwinDsp::ValidationError, "address must be an https:// URL (got #{address.inspect})" unless address.start_with?("https://")
46
49
  return unless address.length > ADDRESS_MAX_LENGTH
47
50
 
48
51
  raise DigiwinDsp::ValidationError,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DigiwinDsp
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -30,6 +30,8 @@ module DigiwinDsp
30
30
  raise(ParseError, "envelope missing digi_body.std_data.parameter.request")
31
31
  end
32
32
 
33
+ private_class_method :parse_json, :extract_request
34
+
33
35
  def initialize(digi_header:, request:, raw:)
34
36
  @digi_header = digi_header
35
37
  @request = request
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: digiwin_dsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-28 00:00:00.000000000 Z
11
+ date: 2026-06-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -68,6 +68,7 @@ files:
68
68
  - lib/digiwin_dsp/authenticator.rb
69
69
  - lib/digiwin_dsp/client.rb
70
70
  - lib/digiwin_dsp/configuration.rb
71
+ - lib/digiwin_dsp/enums.rb
71
72
  - lib/digiwin_dsp/resources/base.rb
72
73
  - lib/digiwin_dsp/resources/cancellation.rb
73
74
  - lib/digiwin_dsp/resources/invoice.rb