digiwin_dsp 0.3.1 → 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: 3cfde42cbd2b4448e9c5e4927e9ecb65f61ae19a46474154ae1349d73cd2461c
4
- data.tar.gz: 6d35ad2fd9430c5440f626b15f3c98c9a8dd627c2732bb349bc355f6693d9de3
3
+ metadata.gz: 8b74fbc1739d80f99a563f002be9ab06212eb0ffce531ddf9144c9c8ceb28b34
4
+ data.tar.gz: 363e1faecee60d9a4c8f5cfe03b00bf2febd35f015f0034451e1c9c9e191308a
5
5
  SHA512:
6
- metadata.gz: 95d2787c766fa0a316542b37d0b5620738e9dcff687562875698e5c1d0f3a3ffaaa2e93a81f24b34e0f6e27047a9c53d22e1368725879f36a6f48116b02f2b7c
7
- data.tar.gz: c8f1b7781c06e3fa745d4e2a6770d7442dd443a38e10ca4ce6767b5aaa82bbf1016ef8aeb961922ebe9f41321d6702be561a1c82810223335a0a340c6f6eb27b
6
+ metadata.gz: b14060dbe2c6d65890ffb925c1ee9f499a7870db0c4eb301646eaf0ea2907869e0e331d8ff14b76e1a6bd474a2de5bf75f97d9bdf5073d856a5c55b6753dd225
7
+ data.tar.gz: 1f1f7ab22cd0e077e455ce7cc6451442e5285ccf7e1c94a1abcb0c6e284e3fc86f94cf879170b4ddd2cc3d747ca532ba64a6082bf89be972aacadef23df33e7c
data/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@ 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
+
9
34
  ## [0.3.1] - 2026-06-12
10
35
 
11
36
  Hardening patch from a full gem + docs review. All fixes grounded in the vendor YAML specs.
@@ -226,7 +251,8 @@ Initial release. Covers the four Self-hosted Website Module (自有官網模組)
226
251
  - The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
227
252
  - Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
228
253
 
229
- [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.1...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
230
256
  [0.3.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.0...v0.3.1
231
257
  [0.3.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.4...v0.3.0
232
258
  [0.2.4]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.3...v0.2.4
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:
@@ -219,6 +220,20 @@ end
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:
@@ -132,11 +132,25 @@ module DigiwinDsp
132
132
  return nil if exec["code"].to_s == "0"
133
133
 
134
134
  { message: exec["description"].to_s, code: exec["code"] }
135
- 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
+
136
138
  { message: body["Message"].to_s, code: body["Status"] }
139
+ else
140
+ warn_unknown_envelope(body)
141
+ nil
137
142
  end
138
143
  end
139
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
+
140
154
  def classify_envelope_failure(message, code:, body:)
141
155
  klass = ENVELOPE_FAILURE_MAP.find { |regex, _| regex.match?(message) }&.last || Error
142
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DigiwinDsp
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: digiwin_dsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
@@ -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