diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index bea5256a..1d6de8da 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -101,6 +101,9 @@ jobs: DOKU_PRIVATE_KEY="${{ secrets.DOKU_PRIVATE_KEY }}" DOKU_MERCHANT_ID=${{ secrets.DOKU_MERCHANT_ID }} DOKU_CHANNEL_ID=${{ secrets.DOKU_CHANNEL_ID }} + DOKU_TERMINAL_ID=${{ secrets.DOKU_TERMINAL_ID }} + DOKU_MERCHANT_POSTAL_CODE=${{ secrets.DOKU_MERCHANT_POSTAL_CODE }} + DOKU_QRIS_FEE_TYPE=${{ secrets.DOKU_QRIS_FEE_TYPE }} DOKU_QRIS_EXPIRY_SECONDS=${{ secrets.DOKU_QRIS_EXPIRY_SECONDS }} ORDER_PAYMENT_WALLET_ID=${{ secrets.ORDER_PAYMENT_WALLET_ID }} APP_ENV=production diff --git a/apps/api/.env.example b/apps/api/.env.example index 80c2f653..6394e384 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -23,7 +23,7 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:4200,http://localhos # credentials exist only in the API host's systemd EnvironmentFile. # NEVER add any of these to apps/order-web or any Vercel project. # -# All five credentials come from the DOKU Back Office SNAP integration page, +# The credentials below come from the DOKU Back Office SNAP integration page, # and they are per-environment: a sandbox credential set sent to # https://api.doku.com (or the reverse) is rejected with # "Unauthorized. Unknown Client", as is a Checkout/Jokul client id used here @@ -35,5 +35,8 @@ DOKU_CLIENT_SECRET= DOKU_PRIVATE_KEY= # PEM, newline-escaped (e.g. \n between lines) DOKU_MERCHANT_ID= DOKU_CHANNEL_ID= -DOKU_QRIS_EXPIRY_SECONDS=300 # QR validity window in seconds (default: 300) +DOKU_TERMINAL_ID= # terminalId on every qr-mpm-generate; alphanumeric, 3-16 chars +DOKU_MERCHANT_POSTAL_CODE= # additionalInfo.postalCode (optional; e.g. 12190) +DOKU_QRIS_FEE_TYPE= # additionalInfo.feeType (optional; leave empty to use the Back Office default) +DOKU_QRIS_EXPIRY_SECONDS=300 # QR validity window in seconds, sent as validityPeriod (default: 300) ORDER_PAYMENT_WALLET_ID= # the QRIS wallet's id; validated by checkout from phase 6 \ No newline at end of file diff --git a/apps/api/cmd/dokucheck/main.go b/apps/api/cmd/dokucheck/main.go index af53b2bb..f438d6b4 100644 --- a/apps/api/cmd/dokucheck/main.go +++ b/apps/api/cmd/dokucheck/main.go @@ -26,6 +26,9 @@ func main() { PrivateKey: privateKey, MerchantId: env.DokuMerchantId, ChannelId: env.DokuChannelId, + TerminalId: env.DokuTerminalId, + PostalCode: env.DokuPostalCode, + FeeType: env.DokuFeeType, } if configErr := config.Validate(); configErr != nil { fmt.Fprintln(os.Stderr, configErr) @@ -36,6 +39,7 @@ func main() { fmt.Printf("clientId: %s\n", config.ClientId) fmt.Printf("merchantId: %s\n", config.MerchantId) fmt.Printf("channelId: %s\n", config.ChannelId) + fmt.Printf("terminalId: %s\n", config.TerminalId) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() diff --git a/apps/api/cmd/dokustub/main.go b/apps/api/cmd/dokustub/main.go index b569cc06..9dff8f5b 100644 --- a/apps/api/cmd/dokustub/main.go +++ b/apps/api/cmd/dokustub/main.go @@ -79,14 +79,36 @@ func main() { mux.HandleFunc("POST /snap-adapter/b2b/v1.0/qr/qr-mpm-generate", func(w http.ResponseWriter, r *http.Request) { var body struct { - PartnerReferenceNo string `json:"partnerReferenceNo"` - Amount qrisAmount `json:"amount"` + PartnerReferenceNo string `json:"partnerReferenceNo"` + Amount qrisAmount `json:"amount"` + MerchantId string `json:"merchantId"` + TerminalId string `json:"terminalId"` + AdditionalInfo json.RawMessage `json:"additionalInfo"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } + for _, field := range []struct { + name string + missing bool + }{ + {"partnerReferenceNo", body.PartnerReferenceNo == ""}, + {"merchantId", body.MerchantId == ""}, + {"terminalId", body.TerminalId == ""}, + {"additionalInfo", len(body.AdditionalInfo) == 0}, + } { + if field.missing { + logger.Warn("dokustub: rejected generate qris", slog.String("field", field.name)) + writeJSON(w, http.StatusBadRequest, map[string]any{ + "responseCode": "4004702", + "responseMessage": "Invalid Mandatory Field " + field.name, + }) + return + } + } + rec := &record{ partnerReferenceNo: body.PartnerReferenceNo, referenceNo: "STUBREF-" + body.PartnerReferenceNo, diff --git a/apps/api/data/doku/payment_repo.go b/apps/api/data/doku/payment_repo.go index 42169660..cfdebec5 100644 --- a/apps/api/data/doku/payment_repo.go +++ b/apps/api/data/doku/payment_repo.go @@ -37,6 +37,9 @@ type Config struct { PrivateKey *rsa.PrivateKey MerchantId string ChannelId string + TerminalId string + PostalCode string + FeeType string } func (c Config) Validate() error { @@ -51,6 +54,7 @@ func (c Config) Validate() error { {"DOKU_CLIENT_SECRET", c.ClientSecret}, {"DOKU_MERCHANT_ID", c.MerchantId}, {"DOKU_CHANNEL_ID", c.ChannelId}, + {"DOKU_TERMINAL_ID", c.TerminalId}, } { if field.value == "" { missing = append(missing, field.name) @@ -136,6 +140,13 @@ func formatTimestamp(t time.Time) string { return t.In(dokuTimeZone).Format("2006-01-02T15:04:05-07:00") } +func formatValidityPeriod(expiredAt time.Time) string { + if expiredAt.IsZero() { + return "" + } + return formatTimestamp(expiredAt) +} + func formatAmount(amount float32) string { return strconv.FormatFloat(float64(amount), 'f', 2, 32) } @@ -233,10 +244,18 @@ type qrisAmount struct { Currency string `json:"currency"` } +type qrisAdditionalInfo struct { + PostalCode string `json:"postalCode,omitempty"` + FeeType string `json:"feeType,omitempty"` +} + type generateQrisRequest struct { - PartnerReferenceNo string `json:"partnerReferenceNo"` - Amount qrisAmount `json:"amount"` - MerchantId string `json:"merchantId"` + PartnerReferenceNo string `json:"partnerReferenceNo"` + Amount qrisAmount `json:"amount"` + MerchantId string `json:"merchantId"` + TerminalId string `json:"terminalId"` + ValidityPeriod string `json:"validityPeriod,omitempty"` + AdditionalInfo qrisAdditionalInfo `json:"additionalInfo"` } type generateQrisResponse struct { @@ -269,6 +288,12 @@ func (c *Client) GenerateQris(ctx context.Context, input domain.GenerateQrisInpu PartnerReferenceNo: input.PartnerReferenceNo, Amount: qrisAmount{Value: formatAmount(input.Amount), Currency: "IDR"}, MerchantId: c.config.MerchantId, + TerminalId: c.config.TerminalId, + ValidityPeriod: formatValidityPeriod(input.ExpiredAt), + AdditionalInfo: qrisAdditionalInfo{ + PostalCode: c.config.PostalCode, + FeeType: c.config.FeeType, + }, } respBody, err := c.doSignedRequest(ctx, qrGeneratePath, reqBody) diff --git a/apps/api/data/doku/payment_repo_test.go b/apps/api/data/doku/payment_repo_test.go index 62c3ec68..e9c580d9 100644 --- a/apps/api/data/doku/payment_repo_test.go +++ b/apps/api/data/doku/payment_repo_test.go @@ -64,6 +64,7 @@ func TestConfigValidate(t *testing.T) { PrivateKey: key, MerchantId: "merchant-id", ChannelId: "channel-id", + TerminalId: "terminal-id", } require.NoError(t, complete.Validate()) @@ -78,6 +79,7 @@ func TestConfigValidate(t *testing.T) { {"client secret", func(c *Config) { c.ClientSecret = "" }, "DOKU_CLIENT_SECRET"}, {"merchant id", func(c *Config) { c.MerchantId = "" }, "DOKU_MERCHANT_ID"}, {"channel id", func(c *Config) { c.ChannelId = "" }, "DOKU_CHANNEL_ID"}, + {"terminal id", func(c *Config) { c.TerminalId = "" }, "DOKU_TERMINAL_ID"}, {"private key", func(c *Config) { c.PrivateKey = nil }, "DOKU_PRIVATE_KEY"}, } @@ -284,6 +286,42 @@ func TestGenerateQris_SendsAmountAsTwoDecimalString(t *testing.T) { assert.Equal(t, "ORD1", gotBody.PartnerReferenceNo) } +func TestGenerateQris_SendsTerminalIdValidityPeriodAndAdditionalInfo(t *testing.T) { + var gotBody generateQrisRequest + client := stubTokenAndPath(t, qrGeneratePath, func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, decodeJSON(r, &gotBody)) + writeJSON(w, generateQrisResponse{ResponseCode: "2004700", PartnerReferenceNo: "ORD1", ReferenceNo: "REF1"}) + }) + + _, err := client.GenerateQris(t.Context(), domain.GenerateQrisInput{ + PartnerReferenceNo: "ORD1", + Amount: 10000, + ExpiredAt: time.Date(2025, 11, 30, 19, 27, 15, 0, dokuTimeZone), + }) + + require.Nil(t, err) + assert.Equal(t, "test-terminal-id", gotBody.TerminalId) + assert.Equal(t, "2025-11-30T19:27:15+07:00", gotBody.ValidityPeriod) + assert.Equal(t, "12190", gotBody.AdditionalInfo.PostalCode) + assert.Equal(t, "1", gotBody.AdditionalInfo.FeeType) +} + +func TestGenerateQris_AlwaysSendsAdditionalInfoObject(t *testing.T) { + var gotBody map[string]any + client := stubTokenAndPath(t, qrGeneratePath, func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, decodeJSON(r, &gotBody)) + writeJSON(w, generateQrisResponse{ResponseCode: "2004700", PartnerReferenceNo: "ORD1", ReferenceNo: "REF1"}) + }) + client.config.PostalCode = "" + client.config.FeeType = "" + + _, err := client.GenerateQris(t.Context(), domain.GenerateQrisInput{PartnerReferenceNo: "ORD1", Amount: 10000}) + + require.Nil(t, err) + assert.Equal(t, map[string]any{}, gotBody["additionalInfo"]) + assert.NotContains(t, gotBody, "validityPeriod") +} + func TestGenerateQris_RejectedResponseCode(t *testing.T) { client := stubTokenAndPath(t, qrGeneratePath, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, generateQrisResponse{ResponseCode: "4004701", ResponseMessage: "Invalid Field Format"}) diff --git a/apps/api/data/doku/token_test.go b/apps/api/data/doku/token_test.go index c5fe0ef3..0b83ff13 100644 --- a/apps/api/data/doku/token_test.go +++ b/apps/api/data/doku/token_test.go @@ -32,6 +32,9 @@ func testClient(t *testing.T, baseURL string) *Client { PrivateKey: key, MerchantId: "test-merchant-id", ChannelId: "test-channel-id", + TerminalId: "test-terminal-id", + PostalCode: "12190", + FeeType: "1", }, httpClient: &http.Client{Timeout: requestTimeout}, token: &tokenCache{}, diff --git a/apps/api/main.go b/apps/api/main.go index 3a135e12..585e364b 100644 --- a/apps/api/main.go +++ b/apps/api/main.go @@ -56,6 +56,9 @@ func main() { PrivateKey: dokuPrivateKey, MerchantId: env.DokuMerchantId, ChannelId: env.DokuChannelId, + TerminalId: env.DokuTerminalId, + PostalCode: env.DokuPostalCode, + FeeType: env.DokuFeeType, } if err := dokuConfig.Validate(); err != nil { rootLogger.Error("invalid doku configuration", slog.Any("error", err)) diff --git a/apps/api/utils/env.go b/apps/api/utils/env.go index 2e61b315..25660346 100644 --- a/apps/api/utils/env.go +++ b/apps/api/utils/env.go @@ -31,6 +31,9 @@ type Env struct { DokuPrivateKey string DokuMerchantId string DokuChannelId string + DokuTerminalId string + DokuPostalCode string + DokuFeeType string DokuQrisExpirySeconds int OrderPaymentWalletId string } @@ -70,6 +73,9 @@ func GetEnv() Env { DokuPrivateKey: os.Getenv("DOKU_PRIVATE_KEY"), DokuMerchantId: getCredential("DOKU_MERCHANT_ID"), DokuChannelId: getCredential("DOKU_CHANNEL_ID"), + DokuTerminalId: getCredential("DOKU_TERMINAL_ID"), + DokuPostalCode: getCredential("DOKU_MERCHANT_POSTAL_CODE"), + DokuFeeType: getCredential("DOKU_QRIS_FEE_TYPE"), DokuQrisExpirySeconds: parseIntWithDefault(os.Getenv("DOKU_QRIS_EXPIRY_SECONDS"), 300), OrderPaymentWalletId: os.Getenv("ORDER_PAYMENT_WALLET_ID"), } diff --git a/apps/api/utils/env_test.go b/apps/api/utils/env_test.go index e1fe22b1..bceaedb1 100644 --- a/apps/api/utils/env_test.go +++ b/apps/api/utils/env_test.go @@ -76,6 +76,9 @@ func TestGetEnv_DokuConfig(t *testing.T) { t.Setenv("DOKU_PRIVATE_KEY", "pem") t.Setenv("DOKU_MERCHANT_ID", "merchant-id") t.Setenv("DOKU_CHANNEL_ID", "channel-id") + t.Setenv("DOKU_TERMINAL_ID", "terminal-id") + t.Setenv("DOKU_MERCHANT_POSTAL_CODE", "12190") + t.Setenv("DOKU_QRIS_FEE_TYPE", "1") t.Setenv("ORDER_PAYMENT_WALLET_ID", "42") env := utils.GetEnv() @@ -86,6 +89,9 @@ func TestGetEnv_DokuConfig(t *testing.T) { assert.Equal(t, "pem", env.DokuPrivateKey) assert.Equal(t, "merchant-id", env.DokuMerchantId) assert.Equal(t, "channel-id", env.DokuChannelId) + assert.Equal(t, "terminal-id", env.DokuTerminalId) + assert.Equal(t, "12190", env.DokuPostalCode) + assert.Equal(t, "1", env.DokuFeeType) assert.Equal(t, "42", env.OrderPaymentWalletId) } @@ -95,6 +101,7 @@ func TestGetEnv_DokuCredentialsAreTrimmedAndUnquoted(t *testing.T) { t.Setenv("DOKU_CLIENT_SECRET", " client-secret ") t.Setenv("DOKU_MERCHANT_ID", "'merchant-id'") t.Setenv("DOKU_CHANNEL_ID", "channel-id\r") + t.Setenv("DOKU_TERMINAL_ID", ` "terminal-id" `) env := utils.GetEnv() @@ -103,4 +110,5 @@ func TestGetEnv_DokuCredentialsAreTrimmedAndUnquoted(t *testing.T) { assert.Equal(t, "client-secret", env.DokuClientSecret) assert.Equal(t, "merchant-id", env.DokuMerchantId) assert.Equal(t, "channel-id", env.DokuChannelId) + assert.Equal(t, "terminal-id", env.DokuTerminalId) } diff --git a/docs/prd-order-checkout-qris-doku.md b/docs/prd-order-checkout-qris-doku.md index e9d8f065..4f675dee 100644 --- a/docs/prd-order-checkout-qris-doku.md +++ b/docs/prd-order-checkout-qris-doku.md @@ -258,7 +258,9 @@ type PaymentGatewayRepository interface { Tests are `httptest`-based and cover: token caching and refresh; a signature computed against a fixed vector; `2004700`-shaped success mapped to `QrisPayment`; `latestTransactionStatus` `"00"` → `paid`, expiry/failure codes → `expired` / `failed`, anything unknown → `pending` (never optimistically `paid`); a notification with a tampered body rejected; a notification older than 5 minutes rejected. -Configuration in `utils/env.go` (all read once, all required when checkout is enabled): `DOKU_BASE_URL`, `DOKU_CLIENT_ID`, `DOKU_CLIENT_SECRET`, `DOKU_PRIVATE_KEY` (PEM), `DOKU_MERCHANT_ID`, `DOKU_CHANNEL_ID`, `DOKU_QRIS_EXPIRY_SECONDS` (default `300` — resolved question 2), `ORDER_PAYMENT_WALLET_ID`. +Configuration in `utils/env.go` (all read once, all required when checkout is enabled): `DOKU_BASE_URL`, `DOKU_CLIENT_ID`, `DOKU_CLIENT_SECRET`, `DOKU_PRIVATE_KEY` (PEM), `DOKU_MERCHANT_ID`, `DOKU_CHANNEL_ID`, `DOKU_TERMINAL_ID`, `DOKU_QRIS_EXPIRY_SECONDS` (default `300` — resolved question 2), `ORDER_PAYMENT_WALLET_ID`, plus the optional `DOKU_MERCHANT_POSTAL_CODE` and `DOKU_QRIS_FEE_TYPE`. + +`qr-mpm-generate` carries `partnerReferenceNo`, `amount`, `merchantId`, **`terminalId`** (mandatory, alphanumeric 3–16), `validityPeriod` (derived from `DOKU_QRIS_EXPIRY_SECONDS`) and **`additionalInfo`** — a mandatory object whose `postalCode` and `feeType` are optional and configuration-driven, so the object is always sent even when both are unset. ### FR-4 — `customers`: the guest's name (API) @@ -450,6 +452,9 @@ All customer-facing copy is Bahasa Indonesia (D15 of `prd-table-ordering.md`), m | `DOKU_PRIVATE_KEY` | `apps/api/.env` | PEM, newline-escaped. Asymmetric signing key. **Secret.** | | `DOKU_MERCHANT_ID` | `apps/api/.env` | Required by `qr-mpm-query`. | | `DOKU_CHANNEL_ID` | `apps/api/.env` | `CHANNEL-ID` header. | +| `DOKU_TERMINAL_ID` | `apps/api/.env` | `terminalId` on `qr-mpm-generate`. Mandatory, alphanumeric, 3–16 characters. | +| `DOKU_MERCHANT_POSTAL_CODE` | `apps/api/.env` | `additionalInfo.postalCode`. Optional; omitted from the body when unset. | +| `DOKU_QRIS_FEE_TYPE` | `apps/api/.env` | `additionalInfo.feeType`. Optional; omitted when unset, so DOKU applies the Back Office default. | | `DOKU_QRIS_EXPIRY_SECONDS` | `apps/api/.env` | Default `300` (5 minutes, resolved question 2). Drives the QR's expiry, `payments.expired_at`, and the countdown the guest sees. | | `ORDER_PAYMENT_WALLET_ID` | `apps/api/.env` | The `QRIS` wallet (D15). Validated at boot. |