Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 4 additions & 0 deletions apps/api/cmd/dokucheck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
26 changes: 24 additions & 2 deletions apps/api/cmd/dokustub/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 28 additions & 3 deletions apps/api/data/doku/payment_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions apps/api/data/doku/payment_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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"},
}

Expand Down Expand Up @@ -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"})
Expand Down
3 changes: 3 additions & 0 deletions apps/api/data/doku/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
3 changes: 3 additions & 0 deletions apps/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 6 additions & 0 deletions apps/api/utils/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ type Env struct {
DokuPrivateKey string
DokuMerchantId string
DokuChannelId string
DokuTerminalId string
DokuPostalCode string
DokuFeeType string
DokuQrisExpirySeconds int
OrderPaymentWalletId string
}
Expand Down Expand Up @@ -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"),
}
Expand Down
8 changes: 8 additions & 0 deletions apps/api/utils/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
}

Expand All @@ -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()

Expand All @@ -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)
}
7 changes: 6 additions & 1 deletion docs/prd-order-checkout-qris-doku.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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. |

Expand Down
Loading