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
4 changes: 4 additions & 0 deletions platforms/android/lib/api/lib.api
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public final class com/shopify/checkoutkit/Checkout {
public static final field Companion Lcom/shopify/checkoutkit/Checkout$Companion;
public synthetic fun <init> (Lcom/shopify/checkoutkit/Checkout$State;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun equals (Ljava/lang/Object;)Z
public final fun getActions ()Ljava/util/Map;
public final fun getAdditionalProperties ()Ljava/util/Map;
public final fun getAttribution ()Ljava/util/Map;
public final fun getBuyer ()Lcom/shopify/ucp/embedded/checkout/Buyer;
Expand All @@ -25,6 +26,7 @@ public final class com/shopify/checkoutkit/Checkout {
public final fun getMessages ()Ljava/util/List;
public final fun getOrder ()Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;
public final fun getPayment ()Lcom/shopify/ucp/embedded/checkout/Payment;
public final fun getPolicies ()Ljava/util/List;
public final fun getSignals ()Lkotlinx/serialization/json/JsonObject;
public final fun getStatus ()Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;
public final fun getTotals ()Ljava/util/List;
Expand All @@ -35,6 +37,7 @@ public final class com/shopify/checkoutkit/Checkout {

public final class com/shopify/checkoutkit/Checkout$Builder {
public fun <init> ()V
public final fun actions (Ljava/util/Map;)Lcom/shopify/checkoutkit/Checkout$Builder;
public final fun additionalProperties (Ljava/util/Map;)Lcom/shopify/checkoutkit/Checkout$Builder;
public final fun attribution (Ljava/util/Map;)Lcom/shopify/checkoutkit/Checkout$Builder;
public final fun build ()Lcom/shopify/checkoutkit/Checkout;
Expand All @@ -51,6 +54,7 @@ public final class com/shopify/checkoutkit/Checkout$Builder {
public final fun messages (Ljava/util/List;)Lcom/shopify/checkoutkit/Checkout$Builder;
public final fun order (Lcom/shopify/ucp/embedded/checkout/OrderConfirmation;)Lcom/shopify/checkoutkit/Checkout$Builder;
public final fun payment (Lcom/shopify/ucp/embedded/checkout/Payment;)Lcom/shopify/checkoutkit/Checkout$Builder;
public final fun policies (Ljava/util/List;)Lcom/shopify/checkoutkit/Checkout$Builder;
public final fun signals (Lkotlinx/serialization/json/JsonObject;)Lcom/shopify/checkoutkit/Checkout$Builder;
public final fun status (Lcom/shopify/ucp/embedded/checkout/CheckoutStatus;)Lcom/shopify/checkoutkit/Checkout$Builder;
public final fun totals (Ljava/util/List;)Lcom/shopify/checkoutkit/Checkout$Builder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.shopify.ucp.embedded.checkout.Link
import com.shopify.ucp.embedded.checkout.Message
import com.shopify.ucp.embedded.checkout.OrderConfirmation
import com.shopify.ucp.embedded.checkout.Payment
import com.shopify.ucp.embedded.checkout.Policy
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
Expand All @@ -27,6 +28,9 @@ import com.shopify.ucp.embedded.checkout.Checkout as ProtocolCheckout
*/
@Serializable(with = CheckoutSerializer::class)
public class Checkout private constructor(private val state: State) {
public val actions: Map<String, List<JsonObject>>?
get() = state.actions

public val attribution: Map<String, String>?
get() = state.attribution

Expand Down Expand Up @@ -69,6 +73,9 @@ public class Checkout private constructor(private val state: State) {
public val payment: Payment?
get() = state.payment

public val policies: List<Policy>?
get() = state.policies

public val signals: JsonObject?
get() = state.signals

Expand All @@ -83,6 +90,7 @@ public class Checkout private constructor(private val state: State) {

/** Returns a builder containing all values from this snapshot. */
public fun toBuilder(): Builder = Builder()
.actions(actions)
.attribution(attribution)
.buyer(buyer)
.context(context)
Expand All @@ -97,6 +105,7 @@ public class Checkout private constructor(private val state: State) {
.messages(messages)
.order(order)
.payment(payment)
.policies(policies)
.signals(signals)
.status(status)
.totals(totals)
Expand All @@ -116,6 +125,7 @@ public class Checkout private constructor(private val state: State) {
*/
@Suppress("TooManyFunctions")
public class Builder {
private var actions: Map<String, List<JsonObject>>? = null
private var attribution: Map<String, String>? = null
private var buyer: Buyer? = null
private var context: Context? = null
Expand All @@ -130,11 +140,14 @@ public class Checkout private constructor(private val state: State) {
private var messages: List<Message>? = null
private var order: OrderConfirmation? = null
private var payment: Payment? = null
private var policies: List<Policy>? = null
private var signals: JsonObject? = null
private var status: CheckoutStatus? = null
private var totals: List<CheckoutTotal>? = null
private var additionalProperties: Map<String, JsonElement> = emptyMap()

public fun actions(value: Map<String, List<JsonObject>>?): Builder = apply { actions = value }

public fun attribution(value: Map<String, String>?): Builder = apply { attribution = value }

public fun buyer(value: Buyer?): Builder = apply { buyer = value }
Expand Down Expand Up @@ -163,6 +176,8 @@ public class Checkout private constructor(private val state: State) {

public fun payment(value: Payment?): Builder = apply { payment = value }

public fun policies(value: List<Policy>?): Builder = apply { policies = value }

public fun signals(value: JsonObject?): Builder = apply { signals = value }

public fun status(value: CheckoutStatus): Builder = apply { status = value }
Expand All @@ -178,6 +193,7 @@ public class Checkout private constructor(private val state: State) {
*/
public fun build(): Checkout = Checkout(
State(
actions = actions,
attribution = attribution,
buyer = buyer,
context = context,
Expand All @@ -192,6 +208,7 @@ public class Checkout private constructor(private val state: State) {
messages = messages,
order = order,
payment = payment,
policies = policies,
signals = signals,
status = checkNotNull(status) { "Missing required checkout field: status" },
totals = checkNotNull(totals) { "Missing required checkout field: totals" },
Expand All @@ -202,6 +219,7 @@ public class Checkout private constructor(private val state: State) {

@Suppress("LongParameterList")
private data class State(
val actions: Map<String, List<JsonObject>>?,
val attribution: Map<String, String>?,
val buyer: Buyer?,
val context: Context?,
Expand All @@ -216,6 +234,7 @@ public class Checkout private constructor(private val state: State) {
val messages: List<Message>?,
val order: OrderConfirmation?,
val payment: Payment?,
val policies: List<Policy>?,
val signals: JsonObject?,
val status: CheckoutStatus,
val totals: List<CheckoutTotal>,
Expand All @@ -224,6 +243,7 @@ public class Checkout private constructor(private val state: State) {

public companion object {
internal fun fromProtocol(checkout: ProtocolCheckout): Checkout = Builder()
.actions(checkout.actions)
.attribution(checkout.attribution)
.buyer(checkout.buyer)
.context(checkout.context)
Expand All @@ -238,6 +258,7 @@ public class Checkout private constructor(private val state: State) {
.messages(checkout.messages)
.order(checkout.order)
.payment(checkout.payment)
.policies(checkout.policies)
.signals(checkout.signals)
.status(checkout.status)
.totals(checkout.totals)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ internal object CheckoutSerializer : KSerializer<Checkout> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("com.shopify.checkoutkit.Checkout")

internal val reservedKeys: Set<String> = setOf(
"attribution", "buyer", "context", "continue_url", "currency", "discounts", "expires_at", "fulfillment",
"id", "line_items", "links", "messages", "order", "payment", "signals", "status", "totals", "ucp",
"actions", "attribution", "buyer", "context", "continue_url", "currency", "discounts", "expires_at", "fulfillment",
"id", "line_items", "links", "messages", "order", "payment", "policies", "signals", "status", "totals", "ucp",
)

override fun deserialize(decoder: Decoder): Checkout {
Expand All @@ -29,6 +29,7 @@ internal object CheckoutSerializer : KSerializer<Checkout> {
?: throw SerializationException("Checkout must be a JSON object")
val json = input.json
return Checkout.Builder()
.actions(fields.optional("actions", json))
.attribution(fields.optional("attribution", json))
.buyer(fields.optional("buyer", json))
.context(fields.optional("context", json))
Expand All @@ -43,6 +44,7 @@ internal object CheckoutSerializer : KSerializer<Checkout> {
.messages(fields.optional("messages", json))
.order(fields.optional("order", json))
.payment(fields.optional("payment", json))
.policies(fields.optional("policies", json))
.signals(fields.optional("signals", json))
.status(fields.required("status", json))
.totals(fields.required("totals", json))
Expand All @@ -55,27 +57,33 @@ internal object CheckoutSerializer : KSerializer<Checkout> {
?: throw SerializationException("Checkout can only be serialized to JSON")
val json = output.json
val fields = linkedMapOf<String, JsonElement>()
value.attribution?.let { fields["attribution"] = json.encodeToJsonElement(it) }
value.buyer?.let { fields["buyer"] = json.encodeToJsonElement(it) }
value.context?.let { fields["context"] = json.encodeToJsonElement(it) }
value.continueURL?.let { fields["continue_url"] = json.encodeToJsonElement(it) }
fields.putOptional("actions", value.actions, json)
fields.putOptional("attribution", value.attribution, json)
fields.putOptional("buyer", value.buyer, json)
fields.putOptional("context", value.context, json)
fields.putOptional("continue_url", value.continueURL, json)
fields["currency"] = json.encodeToJsonElement(value.currency)
value.discounts?.let { fields["discounts"] = json.encodeToJsonElement(it) }
value.expiresAt?.let { fields["expires_at"] = json.encodeToJsonElement(it) }
value.fulfillment?.let { fields["fulfillment"] = json.encodeToJsonElement(it) }
fields.putOptional("discounts", value.discounts, json)
fields.putOptional("expires_at", value.expiresAt, json)
fields.putOptional("fulfillment", value.fulfillment, json)
fields["id"] = json.encodeToJsonElement(value.id)
fields["line_items"] = json.encodeToJsonElement(value.lineItems)
fields["links"] = json.encodeToJsonElement(value.links)
value.messages?.let { fields["messages"] = json.encodeToJsonElement(it) }
value.order?.let { fields["order"] = json.encodeToJsonElement(it) }
value.payment?.let { fields["payment"] = json.encodeToJsonElement(it) }
value.signals?.let { fields["signals"] = json.encodeToJsonElement(it) }
fields.putOptional("messages", value.messages, json)
fields.putOptional("order", value.order, json)
fields.putOptional("payment", value.payment, json)
fields.putOptional("policies", value.policies, json)
fields.putOptional("signals", value.signals, json)
fields["status"] = json.encodeToJsonElement(value.status)
fields["totals"] = json.encodeToJsonElement(value.totals)
value.additionalProperties.filterKeys { it !in reservedKeys }.forEach { (key, element) -> fields[key] = element }
output.encodeJsonElement(JsonObject(fields))
}

private inline fun <reified T> MutableMap<String, JsonElement>.putOptional(key: String, value: T?, json: Json) {
if (value != null) this[key] = json.encodeToJsonElement(value)
}

private inline fun <reified T> JsonObject.required(key: String, json: Json): T =
json.decodeFromJsonElement(get(key) ?: throw SerializationException("Missing $key for Checkout"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ class CheckoutCompletionCacheTest {
"params": {"checkout": {
"id": "checkout-example", "currency": "USD", "status": "completed",
"line_items": [], "links": [], "totals": [],
"ucp": {"payment_handlers": {}, "version": "2026-04-08"}
"ucp": {"payment_handlers": {}, "version": "${CheckoutProtocol.SPEC_VERSION}"}
}}
}
""".trimIndent()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,33 @@ class CheckoutEventAdapterTest {
assertThat(updated.map { it.checkout.totals.single().amount }).containsExactly(1L, 2L, 3L, 4L)
}

@Test
fun `actions and policies survive lifecycle events and participate in deduplication`() {
val adapter = CheckoutEventAdapter(listener)
adapter.process(message("ec.start"))
assertThat(started.single().checkout.actions?.get("com.example.review")).hasSize(1)
assertThat(started.single().checkout.policies?.single()?.description?.plain).isEqualTo("Return within 30 days")

val changedAction = message("ec.messages.change").replace("action-1", "action-2")
adapter.process(changedAction)
adapter.process(changedAction)
assertThat(updated).hasSize(1)
val changedPolicy = changedAction.replace("Return within 30 days", "Return within 60 days")
adapter.process(changedPolicy)
adapter.process(changedPolicy)
assertThat(updated).hasSize(2)
assertThat(updated.last().checkout.policies?.single()?.description?.plain).isEqualTo("Return within 60 days")

adapter.process(changedPolicy.replace("ec.messages.change", "ec.complete"))
assertThat(completed.single().checkout).isEqualTo(updated.last().checkout)
}

@Test
fun `equal updates across sources and ucp-only changes are suppressed`() {
val adapter = CheckoutEventAdapter(listener)
adapter.process(message("ec.start"))
adapter.process(message("ec.messages.change"))
adapter.process(message("ec.totals.change", version = "2099-01-01"))
adapter.process(message("ec.totals.change", version = "different-version"))
adapter.process(message("ec.fulfillment.change", total = 1))
adapter.process(message("ec.line_items.change", total = 1))

Expand Down Expand Up @@ -130,6 +151,8 @@ class CheckoutEventAdapterTest {
extension: String = "value",
): String = """{
"jsonrpc":"2.0","method":"$method","params":{"checkout":{
"actions":{"com.example.review":[{"id":"action-1"}]},
"policies":[{"type":"com.example.return","description":{"plain":"Return within 30 days"}}],
"id":"checkout-1","currency":"USD","status":"incomplete","line_items":[],"links":[],
"messages":[{"type":"error","code":"out_of_stock","content":"Unavailable"}],
"totals":[{"type":"total","amount":$total}],"com.example.extension":"$extension",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import com.shopify.ucp.embedded.checkout.EmbeddedColorScheme
import com.shopify.ucp.embedded.checkout.EmbeddedTransportConfig
import com.shopify.ucp.embedded.checkout.ErrorResponse
import com.shopify.ucp.embedded.checkout.ErrorStatus
import com.shopify.ucp.embedded.checkout.FulfillmentMethodType
Comment thread
markmur marked this conversation as resolved.
import com.shopify.ucp.embedded.checkout.LineItemQuantity
import com.shopify.ucp.embedded.checkout.LineItemStatus
import com.shopify.ucp.embedded.checkout.Message
Expand Down Expand Up @@ -349,14 +348,14 @@ class CheckoutProtocolTest {
.on(CheckoutProtocol.error) { received = it }

val errorMsg = """{"jsonrpc":"2.0","method":"ec.error","params":""" +
"""{"error":{"ucp":{"version":"2026-04-08","status":"error"},"messages":[""" +
"""{"error":{"ucp":{"version":"${CheckoutProtocol.SPEC_VERSION}","status":"error"},"messages":[""" +
"""{"type":"error","code":"unknown_error","content":"fail","severity":"unrecoverable"},""" +
"""{"type":"error","code":"session_expired","content":"expired","severity":"recoverable"}""" +
"""],"continue_url":"https://example.com/retry"}}}"""
client.process(errorMsg)
shadowOf(Looper.getMainLooper()).runToEndOfTasks()

assertThat(received?.ucp?.version).isEqualTo("2026-04-08")
assertThat(received?.ucp?.version).isEqualTo(CheckoutProtocol.SPEC_VERSION)
assertThat(received?.ucp?.status).isEqualTo(ErrorStatus.Error)
assertThat(received?.messages).hasSize(2)
assertThat(received?.messages?.get(0)?.type).isEqualTo(MessageType.Error)
Expand All @@ -375,7 +374,7 @@ class CheckoutProtocolTest {
.on(CheckoutProtocol.error) {}

val malformed = """{"jsonrpc":"2.0","method":"ec.error","params":""" +
"""{"error":{"ucp":{"version":"2026-04-08","status":"error"},"messages":"not-an-array"}}}"""
"""{"error":{"ucp":{"version":"${CheckoutProtocol.SPEC_VERSION}","status":"error"},"messages":"not-an-array"}}}"""
client.process(malformed)

assertThat(
Expand Down Expand Up @@ -408,7 +407,7 @@ class CheckoutProtocolTest {
.on(CheckoutProtocol.error) {}

val malformed = """{"jsonrpc":"2.0","method":"ec.error","params":""" +
"""{"error":{"ucp":{"version":"2026-04-08","status":"error"},"messages":"not-an-array"}}}"""
"""{"error":{"ucp":{"version":"${CheckoutProtocol.SPEC_VERSION}","status":"error"},"messages":"not-an-array"}}}"""
client.process(malformed)

assertThat(
Expand All @@ -425,7 +424,7 @@ class CheckoutProtocolTest {
.on(CheckoutProtocol.error) {}

val malformed = """{"jsonrpc":"2.0","method":"ec.error","params":""" +
"""{"error":{"ucp":{"version":"2026-04-08","status":"error"},"messages":"not-an-array"}}}"""
"""{"error":{"ucp":{"version":"${CheckoutProtocol.SPEC_VERSION}","status":"error"},"messages":"not-an-array"}}}"""
client.process(malformed)

assertThat(
Expand Down Expand Up @@ -496,7 +495,7 @@ class CheckoutProtocolTest {
"totals": [],
"ucp": {
"payment_handlers": {},
"version": "2026-04-08"
"version": "${CheckoutProtocol.SPEC_VERSION}"
}
}
""".trimIndent(),
Expand All @@ -507,10 +506,10 @@ class CheckoutProtocolTest {
assertThat(checkout.discounts?.applied?.get(0)?.allocations?.get(0)?.path)
.isEqualTo("\$.line_items[0]")
assertThat(checkout.fulfillment?.availableMethods?.get(0)?.type)
.isEqualTo(FulfillmentMethodType.Shipping)
.isEqualTo("shipping")
assertThat(checkout.fulfillment?.methods?.get(0)?.id).isEqualTo("pickup-main")
assertThat(checkout.fulfillment?.methods?.get(0)?.type)
.isEqualTo(FulfillmentMethodType.Pickup)
.isEqualTo("pickup")
}

@Test
Expand Down
Loading
Loading