diff --git a/lib/src/protocol/json_schemas/cancel_schema.dart b/lib/src/protocol/json_schemas/cancel_schema.dart new file mode 100644 index 0000000..7645682 --- /dev/null +++ b/lib/src/protocol/json_schemas/cancel_schema.dart @@ -0,0 +1,14 @@ +class CancelSchema { + static const String json = r''' + { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://tbdex.dev/cancel.schema.json", + "type": "object", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + } + } + }'''; +} \ No newline at end of file diff --git a/lib/src/protocol/json_schemas/message_schema.dart b/lib/src/protocol/json_schemas/message_schema.dart index 9745be8..0555361 100644 --- a/lib/src/protocol/json_schemas/message_schema.dart +++ b/lib/src/protocol/json_schemas/message_schema.dart @@ -18,7 +18,7 @@ class MessageSchema { }, "kind": { "type": "string", - "enum": ["rfq", "quote", "order", "orderstatus", "close"], + "enum": ["rfq", "quote", "order", "orderstatus", "close", "cancel"], "description": "The message kind (e.g. rfq, quote)" }, "id": { diff --git a/lib/src/protocol/json_schemas/offering_schema.dart b/lib/src/protocol/json_schemas/offering_schema.dart index 4276690..be19066 100644 --- a/lib/src/protocol/json_schemas/offering_schema.dart +++ b/lib/src/protocol/json_schemas/offering_schema.dart @@ -144,13 +144,32 @@ class OfferingSchema { "requiredClaims": { "type": "object", "description": "PresentationDefinition that describes the credential(s) the PFI requires in order to provide a quote." + }, + "cancellation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether cancellation is enabled for this offering" + }, + "termsUrl": { + "type": "string", + "description": "A link to a page that describes the terms of cancellation" + }, + "terms": { + "type": "string", + "description": "A human-readable description of the terms of cancellation in plaintext" + } + }, + "required": ["enabled"] } }, "required": [ "description", "payin", "payout", - "payoutUnitsPerPayinUnit" + "payoutUnitsPerPayinUnit", + "cancellation" ] } '''; diff --git a/lib/src/protocol/json_schemas/orderstatus_schema.dart b/lib/src/protocol/json_schemas/orderstatus_schema.dart index cbf08dd..8baf0de 100644 --- a/lib/src/protocol/json_schemas/orderstatus_schema.dart +++ b/lib/src/protocol/json_schemas/orderstatus_schema.dart @@ -4,14 +4,30 @@ class OrderstatusSchema { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://tbdex.dev/orderstatus.schema.json", "type": "object", - "required": [ - "orderStatus" - ], "additionalProperties": false, "properties": { - "orderStatus": { + "status": { + "type":"string", + "enum": [ + "PAYIN_PENDING", + "PAYIN_INITIATED", + "PAYIN_SETTLED", + "PAYIN_FAILED", + "PAYIN_EXPIRED", + "PAYOUT_PENDING", + "PAYOUT_INITIATED", + "PAYOUT_SETTLED", + "PAYOUT_FAILED", + "REFUND_PENDING", + "REFUND_INITIATED", + "REFUND_SETTLED", + "REFUND_FAILED" + ] + }, + "details": { "type":"string" } - } + }, + "required": ["status"] }'''; } \ No newline at end of file diff --git a/lib/src/protocol/json_schemas/quote_schema.dart b/lib/src/protocol/json_schemas/quote_schema.dart index d379b44..f334c71 100644 --- a/lib/src/protocol/json_schemas/quote_schema.dart +++ b/lib/src/protocol/json_schemas/quote_schema.dart @@ -12,19 +12,23 @@ class QuoteSchema { "type": "string", "description": "ISO 4217 currency code string" }, - "amount": { + "subtotal": { "$ref": "definitions.json#/definitions/decimalString", - "description": "The amount of currency expressed in the smallest respective unit" + "description": "The amount of currency paid for the exchange, excluding fees" }, "fee": { "$ref": "definitions.json#/definitions/decimalString", - "description": "The amount paid in fees" + "description": "The amount of currency paid in fees" + }, + "total": { + "$ref": "definitions.json#/definitions/decimalString", + "description": "The total amount of currency to be paid in or paid out. It is always a sum of subtotal and fee" }, "paymentInstruction": { "$ref": "#/definitions/PaymentInstruction" } }, - "required": ["currencyCode", "amount"] + "required": ["currencyCode", "subtotal", "total"] }, "PaymentInstruction": { "type": "object", @@ -48,6 +52,10 @@ class QuoteSchema { "type": "string", "description": "When this quote expires. Expressed as ISO8601" }, + "payoutUnitsPerPayinUnit": { + "type": "string", + "description": "The exchange rate to convert from payin currency to payout currency. Expressed as an unrounded decimal string." + }, "payin": { "$ref": "#/definitions/QuoteDetails" }, @@ -55,7 +63,7 @@ class QuoteSchema { "$ref": "#/definitions/QuoteDetails" } }, - "required": ["expiresAt", "payin", "payout"] + "required": ["expiresAt", "payoutUnitsPerPayinUnit", "payin", "payout"] } '''; } \ No newline at end of file diff --git a/lib/src/protocol/models/cancel.dart b/lib/src/protocol/models/cancel.dart new file mode 100644 index 0000000..b280f20 --- /dev/null +++ b/lib/src/protocol/models/cancel.dart @@ -0,0 +1,69 @@ +import 'package:tbdex/src/protocol/models/message.dart'; +import 'package:tbdex/src/protocol/models/message_data.dart'; +import 'package:tbdex/src/protocol/parser.dart'; + +class Cancel extends Message { + @override + final MessageMetadata metadata; + @override + final CancelData data; + + @override + Set get validNext => {}; + + Cancel._({ + required this.metadata, + required this.data, + String? signature, + }) : super() { + this.signature = signature; + } + + static Cancel create( + String to, + String from, + String exchangeId, + CancelData data, { + String? externalId, + String protocol = '1.0', + }) { + final now = DateTime.now().toUtc().toIso8601String(); + final metadata = MessageMetadata( + kind: MessageKind.cancel, + to: to, + from: from, + id: Message.generateId(MessageKind.cancel), + exchangeId: exchangeId, + createdAt: now, + protocol: protocol, + externalId: externalId, + ); + + return Cancel._( + metadata: metadata, + data: data, + ); + } + + static Future parse(String rawMessage) async { + final cancel = Parser.parseMessage(rawMessage) as Cancel; + await cancel.verify(); + return cancel; + } + + factory Cancel.fromJson(Map json) { + return Cancel._( + metadata: MessageMetadata.fromJson(json['metadata']), + data: CancelData.fromJson(json['data']), + signature: json['signature'], + ); + } + + Map toJson() { + return { + 'metadata': metadata.toJson(), + 'data': data.toJson(), + 'signature': signature, + }; + } +} diff --git a/lib/src/protocol/models/message.dart b/lib/src/protocol/models/message.dart index 69d49fd..9faba1d 100644 --- a/lib/src/protocol/models/message.dart +++ b/lib/src/protocol/models/message.dart @@ -13,6 +13,7 @@ enum MessageKind { rfq, quote, close, + cancel, order, orderstatus, } diff --git a/lib/src/protocol/models/message_data.dart b/lib/src/protocol/models/message_data.dart index e0bcf84..1695761 100644 --- a/lib/src/protocol/models/message_data.dart +++ b/lib/src/protocol/models/message_data.dart @@ -292,6 +292,27 @@ class CloseData extends MessageData { } } +class CancelData extends MessageData { + final String? reason; + + CancelData({ + this.reason, + }); + + factory CancelData.fromJson(Map json) { + return CancelData( + reason: json['reason'], + ); + } + + @override + Map toJson() { + return { + if (reason != null) 'reason': reason, + }; + } +} + class OrderData extends MessageData { @override Map toJson() { diff --git a/lib/src/protocol/parser.dart b/lib/src/protocol/parser.dart index a6f475f..006e676 100644 --- a/lib/src/protocol/parser.dart +++ b/lib/src/protocol/parser.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:tbdex/src/http_client/models/exchange.dart'; import 'package:tbdex/src/protocol/exceptions.dart'; import 'package:tbdex/src/protocol/models/balance.dart'; +import 'package:tbdex/src/protocol/models/cancel.dart'; import 'package:tbdex/src/protocol/models/close.dart'; import 'package:tbdex/src/protocol/models/message.dart'; import 'package:tbdex/src/protocol/models/offering.dart'; @@ -140,6 +141,8 @@ abstract class Parser { return Quote.fromJson(jsonObject); case MessageKind.close: return Close.fromJson(jsonObject); + case MessageKind.cancel: + return Cancel.fromJson(jsonObject); case MessageKind.order: return Order.fromJson(jsonObject); case MessageKind.orderstatus: diff --git a/lib/src/protocol/validator.dart b/lib/src/protocol/validator.dart index 7bc48e7..be51392 100644 --- a/lib/src/protocol/validator.dart +++ b/lib/src/protocol/validator.dart @@ -13,6 +13,7 @@ import 'package:tbdex/src/protocol/json_schemas/quote_schema.dart'; import 'package:tbdex/src/protocol/json_schemas/resource_schema.dart'; import 'package:tbdex/src/protocol/json_schemas/rfq_private_schema.dart'; import 'package:tbdex/src/protocol/json_schemas/rfq_schema.dart'; +import 'package:tbdex/src/protocol/models/cancel.dart'; import 'package:tbdex/src/protocol/models/close.dart'; import 'package:tbdex/src/protocol/models/message.dart'; import 'package:tbdex/src/protocol/models/offering.dart'; @@ -83,6 +84,11 @@ class Validator { _instance._validate(close.toJson(), 'message'); _instance._validate(close.data.toJson(), close.metadata.kind.name); break; + case MessageKind.cancel: + final cancel = message as Cancel; + _instance._validate(cancel.toJson(), 'message'); + _instance._validate(cancel.data.toJson(), cancel.metadata.kind.name); + break; case MessageKind.order: final order = message as Order; _instance._validate(order.toJson(), 'message'); diff --git a/tbdex b/tbdex index 96a1a71..621f54f 160000 --- a/tbdex +++ b/tbdex @@ -1 +1 @@ -Subproject commit 96a1a7164e8e0a608befa31b6cf0c9a4e5cc0f07 +Subproject commit 621f54f078401c1552fc18d6b5f69bc1ba697221 diff --git a/test/helpers/test_data.dart b/test/helpers/test_data.dart index 78349c9..5dd7f3a 100644 --- a/test/helpers/test_data.dart +++ b/test/helpers/test_data.dart @@ -5,6 +5,7 @@ import 'package:tbdex/src/http_client/models/create_exchange_request.dart'; import 'package:tbdex/src/http_client/models/submit_close_request.dart'; import 'package:tbdex/src/http_client/models/submit_order_request.dart'; import 'package:tbdex/src/protocol/models/balance.dart'; +import 'package:tbdex/src/protocol/models/cancel.dart'; import 'package:tbdex/src/protocol/models/close.dart'; import 'package:tbdex/src/protocol/models/message.dart'; import 'package:tbdex/src/protocol/models/message_data.dart'; @@ -200,6 +201,15 @@ class TestData { ); } + static Cancel getCancel({String? to}) { + return Cancel.create( + to ?? pfiDid.uri, + aliceDid.uri, + TypeId.generate(MessageKind.cancel.name), + CancelData(reason: 'reason'), + ); + } + static String getOfferingResponse() { final offering = TestData.getOffering(); final mockOfferings = [offering]; diff --git a/test/protocol/models/cancel_test.dart b/test/protocol/models/cancel_test.dart new file mode 100644 index 0000000..33d8517 --- /dev/null +++ b/test/protocol/models/cancel_test.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +import 'package:tbdex/src/protocol/models/cancel.dart'; +import 'package:tbdex/src/protocol/models/message.dart'; +import 'package:tbdex/src/protocol/models/message_data.dart'; +import 'package:tbdex/tbdex.dart'; +import 'package:test/test.dart'; +import 'package:typeid/typeid.dart'; + +import '../../helpers/test_data.dart'; + +void main() async { + await TestData.initializeDids(); + + group('Cancel', () { + test('can create a new cancel', () { + final cancel = Cancel.create( + TestData.pfi, + TestData.alice, + TypeId.generate(MessageKind.cancel.name), + CancelData(reason: 'my reason'), + ); + + expect(cancel.metadata.id, startsWith(MessageKind.cancel.name)); + expect(cancel.metadata.kind, equals(MessageKind.cancel)); + expect(cancel.metadata.protocol, equals('1.0')); + expect(cancel.data.reason, equals('my reason')); + }); + + test('can parse and verify cancel from a json string', () async { + final cancel = TestData.getCancel(); + await cancel.sign(TestData.aliceDid); + final json = jsonEncode(cancel.toJson()); + final parsed = await Cancel.parse(json); + + expect(parsed, isA()); + expect(parsed.toString(), equals(json)); + }); + }); +}