PayPay Open Payment API(OPA)のWebhookをjacksonしてみた

PayPay決済では、PayPay Open Payment API(OPA)という、決済操作をするAPIを提供しています。 その一つに、PayPay側からイベント通知を行うWebhookを提供しており、以下ようなJSON形式のデータがPostされるようです。 そのJSON形式のデータをjacksonで、デシリアライズしてみました。

https://www.paypay.ne.jp/opa/doc/jp/v1.0/webcashier#section/Webhook

{
  "notification_type": "Transaction",
  "merchant_id": "123456789",
  "store_id": "123456",
  "pos_id": "123",
  "order_id": "123456789123456",
  "merchant_order_id": "A12345",
  "authorized_at": "2020-03-13T13:35:30Z",
  "expires_at": "2020-04-13T13:35:29Z",
  "paid_at": "2020-04-13T13:35:29Z",
  "order_amount": 12345,
  "state": "COMPLETED"
}

クラス名は、Notificationにしました。 また、今後、PayPay側でJSONの仕様変更があるかもしれません。 たとえば、パラメタの追加は、想定しなければいけませんので、 @JsonIgnoreProperties(ignoreUnknown = true)を指定し、新たなパラメタは無視しています。

package paypay.handling;

import com.fasterxml.jackson.annotation.JsonFormat;
import org.apache.commons.lang.builder.ToStringBuilder;
import org.apache.commons.lang3.StringUtils;
import org.codehaus.jackson.annotate.JsonCreator;
import org.codehaus.jackson.annotate.JsonIgnoreProperties;
import org.codehaus.jackson.annotate.JsonProperty;

import java.io.Serializable;
import java.util.Date;

@JsonIgnoreProperties(ignoreUnknown = true)
public class Notification implements Serializable {

    private static final long serialVersionUID = 1L;

    @JsonProperty("authorized_at")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
    private Date authorizedAt;

    @JsonProperty("confirmation_expires_at")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
    private Date confirmationExpiresAt;

    @JsonProperty("expires_at")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
    private Date expiresAt;

    @JsonProperty("merchant_id")
    private String merchantId;

    @JsonProperty("notification_id")
    private String notificationId;

    @JsonProperty("fileType")
    private FileType fileType;

    @JsonProperty("path")
    private String path;

    @JsonProperty("requestedAt")
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
    private Date requestedAt;

    @JsonProperty("merchant_order_id")
    private String merchantOrderId;

    @JsonProperty("notification_type")
    private NotificationType notificationType;

    @JsonProperty("order_amount")
    private Integer orderAmount;

    @JsonProperty("order_id")
    private String orderId;

    @JsonProperty("paid_at")
    private String paidAt;

    @JsonProperty("pos_id")
    private String posId;

    @JsonProperty("state")
    private State state;

    @JsonProperty("store_id")
    private String storeId;

    public Notification() {
    }

    public String getNotificationId() {
        return notificationId;
    }

    public void setNotificationId(String notificationId) {
        this.notificationId = notificationId;
    }

    public FileType getFileType() {
        return fileType;
    }

    public void setFileType(FileType fileType) {
        this.fileType = fileType;
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path;
    }

    public Date getRequestedAt() {
        return requestedAt;
    }

    public void setRequestedAt(Date requestedAt) {
        this.requestedAt = requestedAt;
    }

    public Date getAuthorizedAt() {
        return authorizedAt;
    }

    public void setAuthorizedAt(Date authorizedAt) {
        this.authorizedAt = authorizedAt;
    }

    public Date getConfirmationExpiresAt() {
        return confirmationExpiresAt;
    }

    public void setConfirmationExpiresAt(Date confirmationExpiresAt) {
        this.confirmationExpiresAt = confirmationExpiresAt;
    }

    public Date getExpiresAt() {
        return expiresAt;
    }

    public void setExpiresAt(Date expiresAt) {
        this.expiresAt = expiresAt;
    }

    public String getMerchantId() {
        return merchantId;
    }

    public void setMerchantId(String merchantId) {
        this.merchantId = merchantId;
    }

    public String getMerchantOrderId() {
        return merchantOrderId;
    }

    public void setMerchantOrderId(String merchantOrderId) {
        this.merchantOrderId = merchantOrderId;
    }

    public NotificationType getNotificationType() {
        return notificationType;
    }

    public void setNotificationType(NotificationType notificationType) {
        this.notificationType = notificationType;
    }

    public Integer getOrderAmount() {
        return orderAmount;
    }

    public void setOrderAmount(Integer orderAmount) {
        this.orderAmount = orderAmount;
    }

    public String getOrderId() {
        return orderId;
    }

    public void setOrderId(String orderId) {
        this.orderId = orderId;
    }

    public String getPaidAt() {
        return paidAt;
    }

    public void setPaidAt(String paidAt) {
        this.paidAt = paidAt;
    }

    public String getPosId() {
        return posId;
    }

    public void setPosId(String posId) {
        this.posId = posId;
    }

    public State getState() {
        return state;
    }

    public void setState(State state) {
        this.state = state;
    }

    public String getStoreId() {
        return storeId;
    }

    public void setStoreId(String storeId) {
        this.storeId = storeId;
    }

    @Override
    public String toString() {
        return ToStringBuilder.reflectionToString(this);
    }

    public enum State {
        COMPLETED("COMPLETED"),
        CANCELED("CANCELED"),
        EXPIRED("EXPIRED"),
        AUTHORIZED("AUTHORIZED"),
        FAILED("FAILED"),
        UNKNOWN("UNKNOWN");

        private final String name;

        State(final String name) {
            this.name = name;
        }

        public static State enumOf(final String name) {
            for (State value : values()) {
                if (StringUtils.equals(value.getName(), name)) {
                    return value;
                }
            }
            return UNKNOWN;
        }

        @JsonCreator // This is the factory method and must be static
        public static State fromString(String string) {
            return State.enumOf(string);
        }

        private String getName() {
            return this.name;
        }

    }

    public enum FileType {
        TRANSACTION_RECON("transaction_recon"),
        UNKNOWN("UNKNOWN");

        private final String name;

        FileType(final String name) {
            this.name = name;
        }

        public static FileType enumOf(final String name) {
            for (FileType value : values()) {
                if (StringUtils.equals(value.getName(), name)) {
                    return value;
                }
            }
            return UNKNOWN;
        }

        @JsonCreator // This is the factory method and must be static
        public static FileType fromString(String string) {
            return FileType.enumOf(string);
        }

        private String getName() {
            return this.name;
        }
    }

    public enum NotificationType {
        TRANSACTION("Transaction"),
        FILE_CREATED("file.created"),
        UNKNOWN("UNKNOWN");

        private final String name;

        NotificationType(final String name) {
            this.name = name;
        }

        public static NotificationType enumOf(final String name) {
            for (NotificationType value : values()) {
                if (StringUtils.equals(value.getName(), name)) {
                    return value;
                }
            }
            return UNKNOWN;
        }

        @JsonCreator // This is the factory method and must be static
        public static NotificationType fromString(String string) {
            return NotificationType.enumOf(string);
        }

        private String getName() {
            return this.name;
        }
    }

}

変数名が、 Java側はローワーキャメルケースに対してJSON側はスネークケースと異なるため、@JsonPropertyで、明示的にJSON側の変数名を指定しています。

余談ですが、JSON側のrequestedAtfileTypeだけが、ローワーキャメルケースになっていました。ドキュメント上の誤りなのか、実際、そうなのか不明です。

ついでに言うと、突合ファイルの仕様も、項目名や値が、残高ブロックありが英語で、残高ブロックなしは、日本語になってました。

PayPayの決済システムの初期実装は、アメリカとかインドとかでやって、途中から、日本で引き取った感じでしょうかね。

なんというか、一貫性の無さに一抹の不安を感じざるをえません。

f:id:ryu-htn:20210220213602p:plain

さて、デシリアライズできたか確認します。 SpringMVCなので、以下のような、handlerメソッドを作成して、引数で、Notificationを受けとるようにします。

    @RequestMapping(value = "/webhook.api", method = RequestMethod.POST)
    public void webhookHandler(@RequestBody final Notification notification, HttpServletRequest request, HttpServletResponse response) {
        log.debug("デバッグ: " + notification);
    }

テスト

f:id:ryu-htn:20210220205053p:plain

結果

Notification@4312a609[authorizedAt=Fri Mar 13 22:35:30 JST 2020,confirmationExpiresAt=<null>,expiresAt=Mon Apr 13 22:35:29 JST 2020,merchantId=123456789,notificationId=<null>,fileType=<null>,path=<null>,requestedAt=<null>,merchantOrderId=A12345,notificationType=TRANSACTION,orderAmount=12345,orderId=123456789123456,paidAt=<null>,posId=123,state=AUTHORIZED,storeId=123456]