Skip to content

Commit

Permalink
Extract Client and Parser module.
Browse files Browse the repository at this point in the history
  • Loading branch information
linjunpop committed Jun 5, 2017
1 parent 72cae3b commit 9afbee1
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 95 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ config :receipt_verifier,
shared_secret: "my-secret"
```

### Verify the receipt with the App Store
### Verify the receipt with the App Store server.

```elixir
{:ok, result} = ReceiptVerifier.verify(base64_encoded_receipt_data)
Expand Down Expand Up @@ -88,7 +88,8 @@ receipt = %{"adam_id" => 0, "app_item_id" => 0, "application_version" => "1241",

### Error handling

If there is error, `ReceiptVerifier.verify/1` will return `{:error, %ReceiptVerifier.Error{}}`.
If there is error, `ReceiptVerifier.verify/1`
will return `{:error, %ReceiptVerifier.Error{code: code, message: msg}}`.

An example:

Expand Down
102 changes: 11 additions & 91 deletions lib/receipt_verifier.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,101 +43,21 @@ defmodule ReceiptVerifier do
> Note: If you send sandbox receipt to production server, it will be auto resend to test server. Same for the production receipt.
"""

alias ReceiptVerifier.Client
alias ReceiptVerifier.Parser
alias ReceiptVerifier.Receipt
alias ReceiptVerifier.Error

@production_url 'https://buy.itunes.apple.com/verifyReceipt'
@sandbox_url 'https://sandbox.itunes.apple.com/verifyReceipt'

@doc "Verify receipt with a specific server"
@spec verify(binary, :prod | :test) :: {:ok, Receipt.t} | {:error, Error.t}
def verify(receipt, env \\ :prod) when env in [:test, :prod] do
do_verify_receipt(receipt, env)
end

defp do_verify_receipt(receipt, :prod) do
do_request(receipt, @production_url)
end
defp do_verify_receipt(receipt, :test) do
do_request(receipt, @sandbox_url)
end

defp do_request(receipt, url) do
request_body = prepare_request_body(receipt)
content_type = 'application/json'
request_headers = [
{'Accept', 'application/json'}
]

case :httpc.request(:post, {url, request_headers, content_type, request_body}, [], []) do
{:ok, {{_, 200, _}, _, body}} ->
data = Poison.decode!(body)
case process_response(data) do
{:retry, env} -> do_verify_receipt(receipt, env)
any -> any
end
{:error, reason} ->
{:error, reason}
end
end

defp prepare_request_body(receipt) do
%{
"receipt-data" => receipt
}
|> set_password()
|> Poison.encode!
end

defp process_response(%{"status" => 0, "receipt" => receipt, "latest_receipt" => latest_receipt, "latest_receipt_info" => latest_receipt_info}) do
{:ok, %Receipt{receipt: receipt, latest_receipt: latest_receipt, latest_receipt_info: latest_receipt_info}}
end
defp process_response(%{"status" => 0, "receipt" => receipt}) do
{:ok, %Receipt{receipt: receipt}}
end
defp process_response(%{"status" => 21_000}) do
{:error, %Error{code: 21_000, message: "The App Store could not read the JSON object you provided."}}
end
defp process_response(%{"status" => 21_002}) do
{:error, %Error{code: 21_002, message: "The data in the receipt-data property was malformed or missing."}}
end
defp process_response(%{"status" => 21_003}) do
{:error, %Error{code: 21_003, message: "The receipt could not be authenticated."}}
end
defp process_response(%{"status" => 21_004}) do
{:error, %Error{code: 21_004, message: "The shared secret you provided does not match the shared secret on file for your account."}}
end
defp process_response(%{"status" => 21_005}) do
{:error, %Error{code: 21_005, message: "The receipt server is not currently available."}}
end
defp process_response(%{"status" => 21_006, "receipt" => receipt}) do
{:error, %Error{code: 21_006, message: "This receipt is valid but the subscription has expired"}, receipt: receipt}
end
defp process_response(%{"status" => 21_007}) do
# This receipt is from the test environment,
# but it was sent to the production environment for verification.
# Send it to the test environment instead.
{:retry, :test}
end
defp process_response(%{"status" => 21_008}) do
# This receipt is from the production environment,
# but it was sent to the test environment for verification.
# Send it to the production environment instead.
{:retry, :prod}
end
defp process_response(%{"environment" => _, "exception" => message, "status" => 21_009}) do
# seems like an undocumented error by Apple
# http://stackoverflow.com/questions/37672420/ios-receipt-validation-status-code-21009-what-s-mzinappcacheaccessexception
{:error, %Error{code: 21_009, message: message}}
end

defp set_password(data) do
case Application.get_env(:receipt_verifier, :shared_secret) do
nil ->
data
shared_secret ->
data
|> Map.put("password", shared_secret)
@spec verify(String.t) :: {:ok, Receipt.t} | {:error, Error.t}
def verify(receipt) when is_binary(receipt) do
with(
{:ok, json} <- Client.request(receipt),
{:ok, data} <- Parser.parse(json)
) do
{:ok, data}
else
any -> any
end
end
end
87 changes: 87 additions & 0 deletions lib/receipt_verifier/client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
defmodule ReceiptVerifier.Client do
@moduledoc """
The HTTP Client to send request to App Store
"""

alias ReceiptVerifier.Error

@production "https://buy.itunes.apple.com/verifyReceipt"
@sandbox "https://sandbox.itunes.apple.com/verifyReceipt"

@doc """
Send the iTunes receipt to Apple Store, and parse the response as map
## Example
iex> {:ok, receipt} = ReceiptVerifier.Client.reuqest(base64_encoded_receipt_data)
...> receipt = %{"status" => 0, "receipt" => receipt, "latest_receipt" => latest_receipt, "latest_receipt_info" => latest_receipt_info}
> Note: If you send sandbox receipt to production server, it will be auto resend to test server. Same for the production receipt.
"""
@spec request(String.t, String.t) :: {:ok, map} | {:error, any}
def request(receipt, endpoint \\ @production) do
with(
{:ok, {{_, 200, _}, _, body}} <- do_request(receipt, endpoint),
{:ok, json} <- Poison.decode(body),
:ok <- validate_env(json)
) do
{:ok, json}
else
{:error, :invalid} ->
# Poison error
{:error, %Error{code: 502, message: "The response from Apple's Server is malformed"}}
{:error, {:invalid, msg}} ->
# Poison error
{:error, %Error{code: 502, message: "The response from Apple's Server is malformed: #{msg}"}}
{:retry, endpoint} ->
request(receipt, endpoint)
{:error, reason} ->
{:error, reason}
end
end

defp validate_env(%{"status" => 21_007}) do
# This receipt is from the test environment,
# but it was sent to the production environment for verification.
# Send it to the test environment instead.
{:retry, @sandbox}
end
defp validate_env(%{"status" => 21_008}) do
# This receipt is from the production environment,
# but it was sent to the test environment for verification.
# Send it to the production environment instead.
{:retry, @production}
end
defp validate_env(_) do
:ok
end

defp do_request(receipt, url) do
url = String.to_charlist(url)
request_body = prepare_request_body(receipt)
content_type = 'application/json'
request_headers = [
{'Accept', 'application/json'}
]

:httpc.request(:post, {url, request_headers, content_type, request_body}, [], [])
end

defp prepare_request_body(receipt) do
%{
"receipt-data" => receipt
}
|> set_password()
|> Poison.encode!
end

defp set_password(data) do
case Application.get_env(:receipt_verifier, :shared_secret) do
nil ->
data
shared_secret ->
data
|> Map.put("password", shared_secret)
end
end
end
5 changes: 4 additions & 1 deletion lib/receipt_verifier/error.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ defmodule ReceiptVerifier.Error do
%ReceiptVerifier.Error{code: 21002, message: "The data in the receipt-data property was malformed or missing."}
"""

@type t :: %__MODULE__{code: integer, message: any}
@type t :: %__MODULE__{
code: integer,
message: any
}

defstruct code: nil, message: ""
end
93 changes: 93 additions & 0 deletions lib/receipt_verifier/parser.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
defmodule ReceiptVerifier.Parser do
@moduledoc """
The Parser to parse response from App Store
"""

alias ReceiptVerifier.Receipt
alias ReceiptVerifier.Error

@doc """
Parse the response
## Example
iex> json = %{"status" => 0, "receipt" => receipt, "latest_receipt" => latest_receipt, "latest_receipt_info" => latest_receipt_info}
iex> {:ok, receipt} = ReceiptVerifier.Parser.parse(json)
...> receipt =
%ReceiptVerifier.Receipt{receipt: {"adam_id" => 0, "app_item_id" => 0, "application_version" => "1241",
"bundle_id" => "com.sumiapp.GridDiary", "download_id" => 0,
"in_app" => [%{"is_trial_period" => "false",
"original_purchase_date" => "2014-08-04 06:24:51 Etc/GMT",
"original_purchase_date_ms" => "1407133491000",
"original_purchase_date_pst" => "2014-08-03 23:24:51 America/Los_Angeles",
"original_transaction_id" => "1000000118990828",
"product_id" => "com.sumiapp.GridDiary.pro",
"purchase_date" => "2014-09-02 03:29:06 Etc/GMT",
"purchase_date_ms" => "1409628546000",
"purchase_date_pst" => "2014-09-01 20:29:06 America/Los_Angeles",
"quantity" => "1", "transaction_id" => "1000000118990828"},
%{"is_trial_period" => "false",
"original_purchase_date" => "2014-09-02 03:29:06 Etc/GMT",
"original_purchase_date_ms" => "1409628546000",
"original_purchase_date_pst" => "2014-09-01 20:29:06 America/Los_Angeles",
"original_transaction_id" => "1000000122102348",
"product_id" => "com.sumiapp.griddiary.test",
"purchase_date" => "2014-09-02 03:29:06 Etc/GMT",
"purchase_date_ms" => "1409628546000",
"purchase_date_pst" => "2014-09-01 20:29:06 America/Los_Angeles",
"quantity" => "1", "transaction_id" => "1000000122102348"}],
"original_application_version" => "1.0",
"original_purchase_date" => "2013-08-01 07:00:00 Etc/GMT",
"original_purchase_date_ms" => "1375340400000",
"original_purchase_date_pst" => "2013-08-01 00:00:00 America/Los_Angeles",
"receipt_creation_date" => "2014-09-02 03:29:06 Etc/GMT",
"receipt_creation_date_ms" => "1409628546000",
"receipt_creation_date_pst" => "2014-09-01 20:29:06 America/Los_Angeles",
"receipt_type" => "ProductionSandbox",
"request_date" => "2016-04-29 07:52:28 Etc/GMT",
"request_date_ms" => "1461916348197",
"request_date_pst" => "2016-04-29 00:52:28 America/Los_Angeles",
"version_external_identifier" => 0}}
"""
@spec parse(map()) :: {:ok, Receipt.t} | {:error, Error.t}
def parse(%{"status" => 0, "receipt" => receipt, "latest_receipt" => latest_receipt, "latest_receipt_info" => latest_receipt_info}) do
{:ok, %Receipt{receipt: receipt, latest_receipt: latest_receipt, latest_receipt_info: latest_receipt_info}}
end
def parse(%{"status" => 0, "receipt" => receipt}) do
{:ok, %Receipt{receipt: receipt}}
end
def parse(%{"status" => 21_000}) do
{:error, %Error{code: 21_000, message: "The App Store could not read the JSON object you provided."}}
end
def parse(%{"status" => 21_002}) do
{:error, %Error{code: 21_002, message: "The data in the receipt-data property was malformed or missing."}}
end
def parse(%{"status" => 21_003}) do
{:error, %Error{code: 21_003, message: "The receipt could not be authenticated."}}
end
def parse(%{"status" => 21_004}) do
{:error, %Error{code: 21_004, message: "The shared secret you provided does not match the shared secret on file for your account."}}
end
def parse(%{"status" => 21_005}) do
{:error, %Error{code: 21_005, message: "The receipt server is not currently available."}}
end
def parse(%{"status" => 21_006, "receipt" => _receipt}) do
{:error, %Error{code: 21_006, message: "This receipt is valid but the subscription has expired"}}
end
# def parse(%{"status" => 21_007}) do
# # This receipt is from the test environment,
# # but it was sent to the production environment for verification.
# # Send it to the test environment instead.
# {:retry, :test}
# end
# def parse(%{"status" => 21_008}) do
# # This receipt is from the production environment,
# # but it was sent to the test environment for verification.
# # Send it to the production environment instead.
# {:retry, :prod}
# end
def parse(%{"environment" => _, "exception" => message, "status" => 21_009}) do
# seems like an undocumented error by Apple
# http://stackoverflow.com/questions/37672420/ios-receipt-validation-status-code-21009-what-s-mzinappcacheaccessexception
{:error, %Error{code: 21_009, message: message}}
end
end
7 changes: 6 additions & 1 deletion lib/receipt_verifier/receipt.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ defmodule ReceiptVerifier.Receipt do
The receipt struct
"""

@type t :: %__MODULE__{receipt: map, latest_receipt: binary, latest_receipt_info: [map]}
@type t :: %__MODULE__{
receipt: map,
latest_receipt: String.t,
latest_receipt_info: [map]
}

defstruct [
receipt: nil,
latest_receipt: nil,
Expand Down

0 comments on commit 9afbee1

Please sign in to comment.