diff --git a/contracts/examples/E-1-health/response.json b/contracts/examples/E-1-health/response.json new file mode 100644 index 0000000..889b7e0 --- /dev/null +++ b/contracts/examples/E-1-health/response.json @@ -0,0 +1,6 @@ +{ + "status": "ok", + "server_version": "0.1.0", + "schema_version": 1, + "db_ok": true +} diff --git a/contracts/examples/E-10-delete-me-picture/response.json b/contracts/examples/E-10-delete-me-picture/response.json new file mode 100644 index 0000000..a01f6ee --- /dev/null +++ b/contracts/examples/E-10-delete-me-picture/response.json @@ -0,0 +1,9 @@ +{ + "id": "0192a000-0000-7000-8000-000000000001", + "role": "admin", + "display_name": "Jacob", + "picture_version": null, + "created_at": "2026-10-17T06:12:40.512Z", + "updated_at": "2026-10-17T09:12:00.000Z", + "server_version": 42 +} diff --git a/contracts/examples/E-12-create-invite/request.json b/contracts/examples/E-12-create-invite/request.json new file mode 100644 index 0000000..71ccc2f --- /dev/null +++ b/contracts/examples/E-12-create-invite/request.json @@ -0,0 +1,4 @@ +{ + "kind": "partner", + "target_user_id": null +} diff --git a/contracts/examples/E-12-create-invite/response.json b/contracts/examples/E-12-create-invite/response.json new file mode 100644 index 0000000..830cd2d --- /dev/null +++ b/contracts/examples/E-12-create-invite/response.json @@ -0,0 +1,9 @@ +{ + "id": "0192a002-0000-7000-8000-000000000001", + "kind": "partner", + "target_user_id": null, + "code": "4N8RVT2KW6ZC", + "link": "https://icehack.asuscomm.com:8443/i/4N8RVT2KW6ZC", + "created_at": "2026-10-17T06:30:02.004Z", + "expires_at": "2026-10-20T06:30:02.004Z" +} diff --git a/contracts/examples/E-13-list-invites/response.json b/contracts/examples/E-13-list-invites/response.json new file mode 100644 index 0000000..fab757e --- /dev/null +++ b/contracts/examples/E-13-list-invites/response.json @@ -0,0 +1,13 @@ +{ + "invites": [ + { + "id": "0192a002-0000-7000-8000-000000000002", + "kind": "device", + "target_user_id": "0192a000-0000-7000-8000-000000000002", + "code": null, + "link": null, + "created_at": "2026-10-17T09:00:00.000Z", + "expires_at": "2026-10-20T09:00:00.000Z" + } + ] +} diff --git a/contracts/examples/E-15-list-devices/response.json b/contracts/examples/E-15-list-devices/response.json new file mode 100644 index 0000000..0583150 --- /dev/null +++ b/contracts/examples/E-15-list-devices/response.json @@ -0,0 +1,34 @@ +{ + "devices": [ + { + "id": "0192a001-0000-7000-8000-000000000001", + "user_id": "0192a000-0000-7000-8000-000000000001", + "name": "Galaxy S26", + "app_version_code": 41, + "created_at": "2026-10-17T06:12:40.512Z", + "last_seen_at": "2026-10-17T09:00:00.000Z", + "revoked_at": null, + "is_current": true + }, + { + "id": "0192a001-0000-7000-8000-000000000003", + "user_id": "0192a000-0000-7000-8000-000000000001", + "name": "Old phone", + "app_version_code": null, + "created_at": "2026-10-17T06:20:00.000Z", + "last_seen_at": "2026-10-17T06:25:00.000Z", + "revoked_at": "2026-10-17T06:40:00.000Z", + "is_current": false + }, + { + "id": "0192a001-0000-7000-8000-000000000002", + "user_id": "0192a000-0000-7000-8000-000000000002", + "name": "Galaxy S26+", + "app_version_code": 41, + "created_at": "2026-10-17T07:01:15.000Z", + "last_seen_at": "2026-10-17T08:40:12.000Z", + "revoked_at": null, + "is_current": false + } + ] +} diff --git a/contracts/examples/E-17-push-token/request.json b/contracts/examples/E-17-push-token/request.json new file mode 100644 index 0000000..8141cc8 --- /dev/null +++ b/contracts/examples/E-17-push-token/request.json @@ -0,0 +1,3 @@ +{ + "fcm_token": "fGx1c2hUb2tlbjpBUEE5MWJIUG5vQ2tFeGFtcGxl" +} diff --git a/contracts/examples/E-18-create-account/request.json b/contracts/examples/E-18-create-account/request.json new file mode 100644 index 0000000..846ed30 --- /dev/null +++ b/contracts/examples/E-18-create-account/request.json @@ -0,0 +1,13 @@ +{ + "name": "Vardag", + "default_shares": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 5000 + } + ] +} diff --git a/contracts/examples/E-18-create-account/response.json b/contracts/examples/E-18-create-account/response.json new file mode 100644 index 0000000..61a1160 --- /dev/null +++ b/contracts/examples/E-18-create-account/response.json @@ -0,0 +1,20 @@ +{ + "id": "0192a003-0000-7000-8000-000000000001", + "name": "Vardag", + "default_shares": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 5000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T07:10:00.000Z", + "updated_at": "2026-10-17T07:10:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 13 +} diff --git a/contracts/examples/E-19-patch-account/request.json b/contracts/examples/E-19-patch-account/request.json new file mode 100644 index 0000000..be9a662 --- /dev/null +++ b/contracts/examples/E-19-patch-account/request.json @@ -0,0 +1,13 @@ +{ + "name": "Vardag & mat", + "default_shares": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 6000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 4000 + } + ] +} diff --git a/contracts/examples/E-19-patch-account/response.json b/contracts/examples/E-19-patch-account/response.json new file mode 100644 index 0000000..51b7e2c --- /dev/null +++ b/contracts/examples/E-19-patch-account/response.json @@ -0,0 +1,20 @@ +{ + "id": "0192a003-0000-7000-8000-000000000001", + "name": "Vardag & mat", + "default_shares": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 6000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 4000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T07:10:00.000Z", + "updated_at": "2026-10-17T09:13:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 43 +} diff --git a/contracts/examples/E-2-setup-status/response.json b/contracts/examples/E-2-setup-status/response.json new file mode 100644 index 0000000..966c963 --- /dev/null +++ b/contracts/examples/E-2-setup-status/response.json @@ -0,0 +1,3 @@ +{ + "initialized": true +} diff --git a/contracts/examples/E-20-archive-account/response.json b/contracts/examples/E-20-archive-account/response.json new file mode 100644 index 0000000..e5a7f87 --- /dev/null +++ b/contracts/examples/E-20-archive-account/response.json @@ -0,0 +1,20 @@ +{ + "id": "0192a003-0000-7000-8000-000000000002", + "name": "Resor", + "default_shares": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 5000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T07:10:00.000Z", + "updated_at": "2026-10-17T09:14:00.000Z", + "archived_at": "2026-10-17T09:14:00.000Z", + "deleted_at": null, + "server_version": 44 +} diff --git a/contracts/examples/E-21-unarchive-account/response.json b/contracts/examples/E-21-unarchive-account/response.json new file mode 100644 index 0000000..cfc50d1 --- /dev/null +++ b/contracts/examples/E-21-unarchive-account/response.json @@ -0,0 +1,20 @@ +{ + "id": "0192a003-0000-7000-8000-000000000002", + "name": "Resor", + "default_shares": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 5000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T07:10:00.000Z", + "updated_at": "2026-10-17T09:15:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 45 +} diff --git a/contracts/examples/E-22-delete-account/response.json b/contracts/examples/E-22-delete-account/response.json new file mode 100644 index 0000000..31ed3ff --- /dev/null +++ b/contracts/examples/E-22-delete-account/response.json @@ -0,0 +1,20 @@ +{ + "id": "0192a003-0000-7000-8000-000000000002", + "name": "Resor", + "default_shares": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 5000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T07:10:00.000Z", + "updated_at": "2026-10-17T09:16:00.000Z", + "archived_at": null, + "deleted_at": "2026-10-17T09:16:00.000Z", + "server_version": 46 +} diff --git a/contracts/examples/E-23-create-category/request.json b/contracts/examples/E-23-create-category/request.json new file mode 100644 index 0000000..658a082 --- /dev/null +++ b/contracts/examples/E-23-create-category/request.json @@ -0,0 +1,5 @@ +{ + "name": "Husdjur", + "icon": "pets", + "color": "lime" +} diff --git a/contracts/examples/E-23-create-category/response.json b/contracts/examples/E-23-create-category/response.json new file mode 100644 index 0000000..7135343 --- /dev/null +++ b/contracts/examples/E-23-create-category/response.json @@ -0,0 +1,13 @@ +{ + "id": "0192a007-0000-7000-8000-000000000001", + "default_key": null, + "name": "Husdjur", + "icon": "pets", + "color": "lime", + "sort_order": 110, + "created_at": "2026-10-17T09:17:00.000Z", + "updated_at": "2026-10-17T09:17:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 47 +} diff --git a/contracts/examples/E-24-patch-category/request.json b/contracts/examples/E-24-patch-category/request.json new file mode 100644 index 0000000..4b04f08 --- /dev/null +++ b/contracts/examples/E-24-patch-category/request.json @@ -0,0 +1,4 @@ +{ + "name": "Matvaror", + "sort_order": 15 +} diff --git a/contracts/examples/E-24-patch-category/response.json b/contracts/examples/E-24-patch-category/response.json new file mode 100644 index 0000000..a449581 --- /dev/null +++ b/contracts/examples/E-24-patch-category/response.json @@ -0,0 +1,13 @@ +{ + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries", + "name": "Matvaror", + "icon": "shopping_cart", + "color": "green", + "sort_order": 15, + "created_at": "2026-10-17T00:00:00.000Z", + "updated_at": "2026-10-17T09:18:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 48 +} diff --git a/contracts/examples/E-25-archive-category/response.json b/contracts/examples/E-25-archive-category/response.json new file mode 100644 index 0000000..a5ce134 --- /dev/null +++ b/contracts/examples/E-25-archive-category/response.json @@ -0,0 +1,13 @@ +{ + "id": "0192a007-0000-7000-8000-000000000001", + "default_key": null, + "name": "Husdjur", + "icon": "pets", + "color": "lime", + "sort_order": 110, + "created_at": "2026-10-17T09:17:00.000Z", + "updated_at": "2026-10-17T09:19:00.000Z", + "archived_at": "2026-10-17T09:19:00.000Z", + "deleted_at": null, + "server_version": 49 +} diff --git a/contracts/examples/E-26-unarchive-category/response.json b/contracts/examples/E-26-unarchive-category/response.json new file mode 100644 index 0000000..2093f65 --- /dev/null +++ b/contracts/examples/E-26-unarchive-category/response.json @@ -0,0 +1,13 @@ +{ + "id": "0192a007-0000-7000-8000-000000000001", + "default_key": null, + "name": "Husdjur", + "icon": "pets", + "color": "lime", + "sort_order": 110, + "created_at": "2026-10-17T09:17:00.000Z", + "updated_at": "2026-10-17T09:20:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 50 +} diff --git a/contracts/examples/E-27-delete-category/response.json b/contracts/examples/E-27-delete-category/response.json new file mode 100644 index 0000000..146a04a --- /dev/null +++ b/contracts/examples/E-27-delete-category/response.json @@ -0,0 +1,13 @@ +{ + "id": "0192a007-0000-7000-8000-000000000001", + "default_key": null, + "name": "Husdjur", + "icon": "pets", + "color": "lime", + "sort_order": 110, + "created_at": "2026-10-17T09:17:00.000Z", + "updated_at": "2026-10-17T09:21:00.000Z", + "archived_at": null, + "deleted_at": "2026-10-17T09:21:00.000Z", + "server_version": 51 +} diff --git a/contracts/examples/E-28-create-recurring-template/request.json b/contracts/examples/E-28-create-recurring-template/request.json new file mode 100644 index 0000000..d2a3c09 --- /dev/null +++ b/contracts/examples/E-28-create-recurring-template/request.json @@ -0,0 +1,25 @@ +{ + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000003", + "amount_ore": 1250000, + "description": "Hyra", + "notes": "", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "ratio", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 6000, + "share_ore": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 4000, + "share_ore": null + } + ], + "day_of_month": 25, + "start_period": "2026-10", + "end_period": null, + "paused": false +} diff --git a/contracts/examples/E-28-create-recurring-template/response.json b/contracts/examples/E-28-create-recurring-template/response.json new file mode 100644 index 0000000..d4156da --- /dev/null +++ b/contracts/examples/E-28-create-recurring-template/response.json @@ -0,0 +1,34 @@ +{ + "id": "0192a004-0000-7000-8000-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000003", + "amount_ore": 1250000, + "description": "Hyra", + "notes": "", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "ratio", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 6000, + "share_ore": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 4000, + "share_ore": null + } + ], + "day_of_month": 25, + "start_period": "2026-10", + "end_period": null, + "paused": false, + "last_generated_period": null, + "next_occurrence_date": "2026-10-25", + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T06:30:02.004Z", + "updated_at": "2026-10-17T06:30:02.004Z", + "deleted_at": null, + "server_version": 30 +} diff --git a/contracts/examples/E-29-patch-recurring-template/request.json b/contracts/examples/E-29-patch-recurring-template/request.json new file mode 100644 index 0000000..3d4f6e3 --- /dev/null +++ b/contracts/examples/E-29-patch-recurring-template/request.json @@ -0,0 +1,3 @@ +{ + "paused": true +} diff --git a/contracts/examples/E-29-patch-recurring-template/response.json b/contracts/examples/E-29-patch-recurring-template/response.json new file mode 100644 index 0000000..8142af5 --- /dev/null +++ b/contracts/examples/E-29-patch-recurring-template/response.json @@ -0,0 +1,34 @@ +{ + "id": "0192a004-0000-7000-8000-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000003", + "amount_ore": 1250000, + "description": "Hyra", + "notes": "", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "ratio", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 6000, + "share_ore": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 4000, + "share_ore": null + } + ], + "day_of_month": 25, + "start_period": "2026-10", + "end_period": null, + "paused": true, + "last_generated_period": null, + "next_occurrence_date": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T06:30:02.004Z", + "updated_at": "2026-10-17T09:22:00.000Z", + "deleted_at": null, + "server_version": 52 +} diff --git a/contracts/examples/E-3-invites-accept/request.json b/contracts/examples/E-3-invites-accept/request.json new file mode 100644 index 0000000..cd3ec43 --- /dev/null +++ b/contracts/examples/E-3-invites-accept/request.json @@ -0,0 +1,5 @@ +{ + "code": "7K3MQX9D2HPA", + "device_name": "Galaxy S26", + "display_name": "Jacob" +} diff --git a/contracts/examples/E-3-invites-accept/response.json b/contracts/examples/E-3-invites-accept/response.json new file mode 100644 index 0000000..09a9da3 --- /dev/null +++ b/contracts/examples/E-3-invites-accept/response.json @@ -0,0 +1,33 @@ +{ + "token": "kvt_q7Zm0yHc1u2R9vXgB4kLw8sTn3eP6aJdF5oQ1iYbV0c", + "user": { + "id": "0192a000-0000-7000-8000-000000000001", + "role": "admin", + "display_name": "Jacob", + "picture_version": null, + "created_at": "2026-10-17T06:12:40.512Z", + "updated_at": "2026-10-17T06:12:40.512Z", + "server_version": 11 + }, + "device": { + "id": "0192a001-0000-7000-8000-000000000001", + "user_id": "0192a000-0000-7000-8000-000000000001", + "name": "Galaxy S26", + "app_version_code": 41, + "created_at": "2026-10-17T06:12:40.512Z", + "last_seen_at": "2026-10-17T09:00:00.000Z", + "revoked_at": null, + "is_current": true + }, + "household": [ + { + "id": "0192a000-0000-7000-8000-000000000001", + "role": "admin", + "display_name": "Jacob", + "picture_version": null, + "created_at": "2026-10-17T06:12:40.512Z", + "updated_at": "2026-10-17T06:12:40.512Z", + "server_version": 11 + } + ] +} diff --git a/contracts/examples/E-30-delete-recurring-template/response.json b/contracts/examples/E-30-delete-recurring-template/response.json new file mode 100644 index 0000000..8eb3118 --- /dev/null +++ b/contracts/examples/E-30-delete-recurring-template/response.json @@ -0,0 +1,34 @@ +{ + "id": "0192a004-0000-7000-8000-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000003", + "amount_ore": 1250000, + "description": "Hyra", + "notes": "", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "ratio", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 6000, + "share_ore": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 4000, + "share_ore": null + } + ], + "day_of_month": 25, + "start_period": "2026-10", + "end_period": null, + "paused": false, + "last_generated_period": null, + "next_occurrence_date": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T06:30:02.004Z", + "updated_at": "2026-10-17T09:23:00.000Z", + "deleted_at": "2026-10-17T09:23:00.000Z", + "server_version": 53 +} diff --git a/contracts/examples/E-31-sync-pull/response.json b/contracts/examples/E-31-sync-pull/response.json new file mode 100644 index 0000000..289a9a9 --- /dev/null +++ b/contracts/examples/E-31-sync-pull/response.json @@ -0,0 +1,323 @@ +{ + "epoch": "30b85fb529aac0016dc91650841005b9", + "since": 0, + "next_since": 921, + "has_more": true, + "server_time": "2026-10-25T12:00:00.000Z", + "users": [ + { + "id": "0192a000-0000-7000-8000-000000000001", + "role": "admin", + "display_name": "Jacob", + "picture_version": null, + "created_at": "2026-10-17T06:12:40.512Z", + "updated_at": "2026-10-17T06:12:40.512Z", + "server_version": 11 + }, + { + "id": "0192a000-0000-7000-8000-000000000002", + "role": "member", + "display_name": "Anna", + "picture_version": 2, + "created_at": "2026-10-17T07:01:15.000Z", + "updated_at": "2026-10-17T07:05:00.250Z", + "server_version": 12 + } + ], + "categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries", + "name": null, + "icon": "shopping_cart", + "color": "green", + "sort_order": 10, + "created_at": "2026-10-17T00:00:00.000Z", + "updated_at": "2026-10-17T00:00:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 1 + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants", + "name": null, + "icon": "restaurant", + "color": "orange", + "sort_order": 20, + "created_at": "2026-10-17T00:00:00.000Z", + "updated_at": "2026-10-17T00:00:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 2 + } + ], + "accounts": [ + { + "id": "0192a003-0000-7000-8000-000000000001", + "name": "Vardag", + "default_shares": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 5000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T07:10:00.000Z", + "updated_at": "2026-10-17T07:10:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 13 + } + ], + "recurring_templates": [ + { + "id": "0192a004-0000-7000-8000-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000003", + "amount_ore": 1250000, + "description": "Hyra", + "notes": "", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "ratio", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 6000, + "share_ore": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 4000, + "share_ore": null + } + ], + "day_of_month": 25, + "start_period": "2026-10", + "end_period": null, + "paused": false, + "last_generated_period": "2026-10", + "next_occurrence_date": "2026-11-25", + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T06:30:02.004Z", + "updated_at": "2026-10-25T05:00:00.031Z", + "deleted_at": null, + "server_version": 921 + } + ], + "expenses": [ + { + "id": "01929c5e-1111-7aaa-8bbb-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000001", + "amount_ore": 41200, + "description": "ICA Maxi", + "notes": "", + "expense_date": "2026-10-16", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "default", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ], + "recurring_template_id": null, + "recurring_period": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-16T17:02:11.123Z", + "updated_at": "2026-10-16T17:02:11.123Z", + "deleted_at": null, + "server_version": 913 + }, + { + "id": "01929c5e-1111-7aaa-8bbb-000000000002", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000002", + "amount_ore": 1001, + "description": "Fika", + "notes": "Kanelbullar\noch kaffe", + "expense_date": "2026-10-15", + "payer_user_id": "0192a000-0000-7000-8000-000000000002", + "split_mode": "exact", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 701, + "share_bp": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 300, + "share_bp": null + } + ], + "recurring_template_id": null, + "recurring_period": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000002", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000002", + "created_at": "2026-10-15T10:00:00.000Z", + "updated_at": "2026-10-15T10:00:00.000Z", + "deleted_at": null, + "server_version": 914 + }, + { + "id": "01929c5e-1111-7aaa-8bbb-000000000003", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000003", + "amount_ore": 1250000, + "description": "Hyra", + "notes": "", + "expense_date": "2026-10-25", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "ratio", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 750000, + "share_bp": 6000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 500000, + "share_bp": 4000 + } + ], + "recurring_template_id": "0192a004-0000-7000-8000-000000000001", + "recurring_period": "2026-10", + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-25T05:00:00.031Z", + "updated_at": "2026-10-25T05:00:00.031Z", + "deleted_at": null, + "server_version": 919 + } + ], + "settlements": [ + { + "id": "0192a005-0000-7000-8000-000000000001", + "from_user_id": "0192a000-0000-7000-8000-000000000002", + "to_user_id": "0192a000-0000-7000-8000-000000000001", + "amount_ore": 50000, + "settlement_date": "2026-10-16", + "note": "Swish", + "allocations": [ + { + "account_id": "0192a003-0000-7000-8000-000000000001", + "amount_ore": 30000 + }, + { + "account_id": "0192a003-0000-7000-8000-000000000002", + "amount_ore": 20000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000002", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000002", + "created_at": "2026-10-16T18:00:00.000Z", + "updated_at": "2026-10-16T18:00:00.000Z", + "deleted_at": null, + "server_version": 915 + } + ], + "audit_entries": [ + { + "id": "0192a006-0000-7000-8000-000000000001", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000001", + "action": "create", + "source": "app", + "actor_user_id": "0192a000-0000-7000-8000-000000000001", + "client_at": "2026-10-16T17:02:11.123Z", + "server_at": "2026-10-16T17:02:12.480Z", + "changes": [ + { + "field": "account_id", + "old": null, + "new": "0192a003-0000-7000-8000-000000000001" + }, + { + "field": "category_id", + "old": null, + "new": "01920000-0000-7000-8000-000000000001" + }, + { + "field": "payer_user_id", + "old": null, + "new": "0192a000-0000-7000-8000-000000000001" + }, + { + "field": "amount_ore", + "old": null, + "new": 41200 + }, + { + "field": "description", + "old": null, + "new": "ICA Maxi" + }, + { + "field": "notes", + "old": null, + "new": "" + }, + { + "field": "expense_date", + "old": null, + "new": "2026-10-16" + }, + { + "field": "split_mode", + "old": null, + "new": "default" + }, + { + "field": "splits", + "old": null, + "new": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ] + } + ], + "server_version": 916 + }, + { + "id": "0192a006-0000-7000-8000-000000000002", + "entity_type": "settlement", + "entity_id": "0192a005-0000-7000-8000-000000000001", + "action": "update", + "source": "app", + "actor_user_id": "0192a000-0000-7000-8000-000000000002", + "client_at": "2026-10-16T18:05:00.000Z", + "server_at": "2026-10-16T18:05:01.200Z", + "changes": [ + { + "field": "note", + "old": "", + "new": "Swish" + } + ], + "server_version": 917 + } + ] +} diff --git a/contracts/examples/E-32-sync-push/request.json b/contracts/examples/E-32-sync-push/request.json new file mode 100644 index 0000000..96a1af4 --- /dev/null +++ b/contracts/examples/E-32-sync-push/request.json @@ -0,0 +1,59 @@ +{ + "mutations": [ + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8d9e", + "entity_type": "expense", + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000001", + "amount_ore": 41200, + "description": "ICA Maxi", + "notes": "", + "expense_date": "2026-10-16", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "default", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ], + "created_at": "2026-10-16T17:02:11.123Z", + "updated_at": "2026-10-16T17:02:11.123Z", + "deleted_at": null + } + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8d9f", + "entity_type": "settlement", + "entity": { + "id": "0192a005-0000-7000-8000-000000000001", + "from_user_id": "0192a000-0000-7000-8000-000000000002", + "to_user_id": "0192a000-0000-7000-8000-000000000001", + "amount_ore": 50000, + "settlement_date": "2026-10-16", + "note": "Swish", + "allocations": [ + { + "account_id": "0192a003-0000-7000-8000-000000000001", + "amount_ore": 30000 + }, + { + "account_id": "0192a003-0000-7000-8000-000000000002", + "amount_ore": 20000 + } + ], + "created_at": "2026-10-16T18:00:00.000Z", + "updated_at": "2026-10-16T18:00:00.000Z", + "deleted_at": null + } + } + ] +} diff --git a/contracts/examples/E-32-sync-push/response.json b/contracts/examples/E-32-sync-push/response.json new file mode 100644 index 0000000..396dfc9 --- /dev/null +++ b/contracts/examples/E-32-sync-push/response.json @@ -0,0 +1,75 @@ +{ + "server_time": "2026-10-17T12:00:00.000Z", + "results": [ + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8d9e", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000001", + "status": "accepted", + "server_version": 913, + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000001", + "amount_ore": 41200, + "description": "ICA Maxi", + "notes": "", + "expense_date": "2026-10-16", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "default", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ], + "recurring_template_id": null, + "recurring_period": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-16T17:02:11.123Z", + "updated_at": "2026-10-16T17:02:11.123Z", + "deleted_at": null, + "server_version": 913 + } + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8d9f", + "entity_type": "settlement", + "entity_id": "0192a005-0000-7000-8000-000000000001", + "status": "rejected", + "reason": "stale", + "message": "a newer version exists", + "current": { + "id": "0192a005-0000-7000-8000-000000000001", + "from_user_id": "0192a000-0000-7000-8000-000000000002", + "to_user_id": "0192a000-0000-7000-8000-000000000001", + "amount_ore": 50000, + "settlement_date": "2026-10-16", + "note": "Swish okt", + "allocations": [ + { + "account_id": "0192a003-0000-7000-8000-000000000001", + "amount_ore": 30000 + }, + { + "account_id": "0192a003-0000-7000-8000-000000000002", + "amount_ore": 20000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000002", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000002", + "created_at": "2026-10-16T18:00:00.000Z", + "updated_at": "2026-10-16T19:00:00.000Z", + "deleted_at": null, + "server_version": 918 + } + } + ] +} diff --git a/contracts/examples/E-33-settlement-proposal/request.json b/contracts/examples/E-33-settlement-proposal/request.json new file mode 100644 index 0000000..79b2b96 --- /dev/null +++ b/contracts/examples/E-33-settlement-proposal/request.json @@ -0,0 +1,5 @@ +{ + "payer_user_id": "0192a000-0000-7000-8000-000000000002", + "amount_ore": 60000, + "exclude_settlement_id": null +} diff --git a/contracts/examples/E-33-settlement-proposal/response.json b/contracts/examples/E-33-settlement-proposal/response.json new file mode 100644 index 0000000..935571d --- /dev/null +++ b/contracts/examples/E-33-settlement-proposal/response.json @@ -0,0 +1,16 @@ +{ + "allocations": [ + { + "account_id": "0192a003-0000-7000-8000-000000000001", + "amount_ore": 30000 + }, + { + "account_id": "0192a003-0000-7000-8000-000000000002", + "amount_ore": 30000 + } + ], + "excess_ore": 10000, + "over_settled_account_ids": [ + "0192a003-0000-7000-8000-000000000002" + ] +} diff --git a/contracts/examples/E-5-apk-latest/response.json b/contracts/examples/E-5-apk-latest/response.json new file mode 100644 index 0000000..e716b2c --- /dev/null +++ b/contracts/examples/E-5-apk-latest/response.json @@ -0,0 +1,8 @@ +{ + "version_code": 412, + "version_name": "2026.11.02-1a2b3c4", + "file_name": "kvitt-412.apk", + "size_bytes": 18234567, + "sha256": "9f2c4d1e8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e", + "released_at": "2026-11-02T19:40:00.000Z" +} diff --git a/contracts/examples/E-7-me/response.json b/contracts/examples/E-7-me/response.json new file mode 100644 index 0000000..fa5d5ac --- /dev/null +++ b/contracts/examples/E-7-me/response.json @@ -0,0 +1,42 @@ +{ + "user": { + "id": "0192a000-0000-7000-8000-000000000001", + "role": "admin", + "display_name": "Jacob", + "picture_version": null, + "created_at": "2026-10-17T06:12:40.512Z", + "updated_at": "2026-10-17T06:12:40.512Z", + "server_version": 11 + }, + "device": { + "id": "0192a001-0000-7000-8000-000000000001", + "user_id": "0192a000-0000-7000-8000-000000000001", + "name": "Galaxy S26", + "app_version_code": 41, + "created_at": "2026-10-17T06:12:40.512Z", + "last_seen_at": "2026-10-17T09:00:00.000Z", + "revoked_at": null, + "is_current": true + }, + "household": [ + { + "id": "0192a000-0000-7000-8000-000000000001", + "role": "admin", + "display_name": "Jacob", + "picture_version": null, + "created_at": "2026-10-17T06:12:40.512Z", + "updated_at": "2026-10-17T06:12:40.512Z", + "server_version": 11 + }, + { + "id": "0192a000-0000-7000-8000-000000000002", + "role": "member", + "display_name": "Anna", + "picture_version": 2, + "created_at": "2026-10-17T07:01:15.000Z", + "updated_at": "2026-10-17T07:05:00.250Z", + "server_version": 12 + } + ], + "public_url": "https://icehack.asuscomm.com:8443" +} diff --git a/contracts/examples/E-8-patch-me/request.json b/contracts/examples/E-8-patch-me/request.json new file mode 100644 index 0000000..abeb3b2 --- /dev/null +++ b/contracts/examples/E-8-patch-me/request.json @@ -0,0 +1,3 @@ +{ + "display_name": "Jacob H" +} diff --git a/contracts/examples/E-8-patch-me/response.json b/contracts/examples/E-8-patch-me/response.json new file mode 100644 index 0000000..ded1d0a --- /dev/null +++ b/contracts/examples/E-8-patch-me/response.json @@ -0,0 +1,9 @@ +{ + "id": "0192a000-0000-7000-8000-000000000001", + "role": "admin", + "display_name": "Jacob H", + "picture_version": null, + "created_at": "2026-10-17T06:12:40.512Z", + "updated_at": "2026-10-17T09:10:00.000Z", + "server_version": 40 +} diff --git a/contracts/examples/E-9-put-me-picture/response.json b/contracts/examples/E-9-put-me-picture/response.json new file mode 100644 index 0000000..194840c --- /dev/null +++ b/contracts/examples/E-9-put-me-picture/response.json @@ -0,0 +1,9 @@ +{ + "id": "0192a000-0000-7000-8000-000000000001", + "role": "admin", + "display_name": "Jacob", + "picture_version": 1, + "created_at": "2026-10-17T06:12:40.512Z", + "updated_at": "2026-10-17T09:11:00.000Z", + "server_version": 41 +} diff --git a/contracts/examples/errors/account_not_empty.json b/contracts/examples/errors/account_not_empty.json new file mode 100644 index 0000000..25bc99c --- /dev/null +++ b/contracts/examples/errors/account_not_empty.json @@ -0,0 +1,5 @@ +{ + "code": "account_not_empty", + "message": "account is referenced by expenses, settlements or templates", + "field": null +} diff --git a/contracts/examples/errors/batch_too_large.json b/contracts/examples/errors/batch_too_large.json new file mode 100644 index 0000000..55f319c --- /dev/null +++ b/contracts/examples/errors/batch_too_large.json @@ -0,0 +1,5 @@ +{ + "code": "batch_too_large", + "message": "more than 100 mutations", + "field": "mutations" +} diff --git a/contracts/examples/errors/cannot_revoke_current_device.json b/contracts/examples/errors/cannot_revoke_current_device.json new file mode 100644 index 0000000..60a421b --- /dev/null +++ b/contracts/examples/errors/cannot_revoke_current_device.json @@ -0,0 +1,5 @@ +{ + "code": "cannot_revoke_current_device", + "message": "cannot revoke the calling device", + "field": null +} diff --git a/contracts/examples/errors/category_in_use.json b/contracts/examples/errors/category_in_use.json new file mode 100644 index 0000000..7254e41 --- /dev/null +++ b/contracts/examples/errors/category_in_use.json @@ -0,0 +1,5 @@ +{ + "code": "category_in_use", + "message": "category is used by expenses or templates", + "field": null +} diff --git a/contracts/examples/errors/category_is_default.json b/contracts/examples/errors/category_is_default.json new file mode 100644 index 0000000..9386e93 --- /dev/null +++ b/contracts/examples/errors/category_is_default.json @@ -0,0 +1,5 @@ +{ + "code": "category_is_default", + "message": "default categories cannot be deleted", + "field": null +} diff --git a/contracts/examples/errors/cursor_ahead.json b/contracts/examples/errors/cursor_ahead.json new file mode 100644 index 0000000..143fc8f --- /dev/null +++ b/contracts/examples/errors/cursor_ahead.json @@ -0,0 +1,5 @@ +{ + "code": "cursor_ahead", + "message": "since is greater than the current sequence value", + "field": "since" +} diff --git a/contracts/examples/errors/device_revoked.json b/contracts/examples/errors/device_revoked.json new file mode 100644 index 0000000..ea3ce20 --- /dev/null +++ b/contracts/examples/errors/device_revoked.json @@ -0,0 +1,5 @@ +{ + "code": "device_revoked", + "message": "device is revoked", + "field": null +} diff --git a/contracts/examples/errors/forbidden.json b/contracts/examples/errors/forbidden.json new file mode 100644 index 0000000..ff210e2 --- /dev/null +++ b/contracts/examples/errors/forbidden.json @@ -0,0 +1,5 @@ +{ + "code": "forbidden", + "message": "role member may not manage invites", + "field": null +} diff --git a/contracts/examples/errors/household_full.json b/contracts/examples/errors/household_full.json new file mode 100644 index 0000000..bdaa0e2 --- /dev/null +++ b/contracts/examples/errors/household_full.json @@ -0,0 +1,5 @@ +{ + "code": "household_full", + "message": "the household already has two users", + "field": null +} diff --git a/contracts/examples/errors/household_incomplete.json b/contracts/examples/errors/household_incomplete.json new file mode 100644 index 0000000..564dd03 --- /dev/null +++ b/contracts/examples/errors/household_incomplete.json @@ -0,0 +1,5 @@ +{ + "code": "household_incomplete", + "message": "the partner has not joined yet", + "field": null +} diff --git a/contracts/examples/errors/image_invalid.json b/contracts/examples/errors/image_invalid.json new file mode 100644 index 0000000..72c5cbc --- /dev/null +++ b/contracts/examples/errors/image_invalid.json @@ -0,0 +1,5 @@ +{ + "code": "image_invalid", + "message": "picture cannot be decoded", + "field": null +} diff --git a/contracts/examples/errors/internal.json b/contracts/examples/errors/internal.json new file mode 100644 index 0000000..afd68a8 --- /dev/null +++ b/contracts/examples/errors/internal.json @@ -0,0 +1,5 @@ +{ + "code": "internal", + "message": "internal error, request id 0192a0ff-0000-7000-8000-000000000001", + "field": null +} diff --git a/contracts/examples/errors/invalid_request.json b/contracts/examples/errors/invalid_request.json new file mode 100644 index 0000000..edd274b --- /dev/null +++ b/contracts/examples/errors/invalid_request.json @@ -0,0 +1,5 @@ +{ + "code": "invalid_request", + "message": "missing field `device_name`", + "field": "device_name" +} diff --git a/contracts/examples/errors/invite_invalid.json b/contracts/examples/errors/invite_invalid.json new file mode 100644 index 0000000..920a2d8 --- /dev/null +++ b/contracts/examples/errors/invite_invalid.json @@ -0,0 +1,5 @@ +{ + "code": "invite_invalid", + "message": "invite code is not valid", + "field": null +} diff --git a/contracts/examples/errors/name_taken.json b/contracts/examples/errors/name_taken.json new file mode 100644 index 0000000..fb9ee08 --- /dev/null +++ b/contracts/examples/errors/name_taken.json @@ -0,0 +1,5 @@ +{ + "code": "name_taken", + "message": "an account with this name exists", + "field": "name" +} diff --git a/contracts/examples/errors/no_accounts.json b/contracts/examples/errors/no_accounts.json new file mode 100644 index 0000000..5385a5a --- /dev/null +++ b/contracts/examples/errors/no_accounts.json @@ -0,0 +1,5 @@ +{ + "code": "no_accounts", + "message": "no_accounts", + "field": null +} diff --git a/contracts/examples/errors/not_found.json b/contracts/examples/errors/not_found.json new file mode 100644 index 0000000..0b7cec8 --- /dev/null +++ b/contracts/examples/errors/not_found.json @@ -0,0 +1,5 @@ +{ + "code": "not_found", + "message": "no such account", + "field": null +} diff --git a/contracts/examples/errors/not_implemented.json b/contracts/examples/errors/not_implemented.json new file mode 100644 index 0000000..638f7a4 --- /dev/null +++ b/contracts/examples/errors/not_implemented.json @@ -0,0 +1,5 @@ +{ + "code": "not_implemented", + "message": "not implemented", + "field": null +} diff --git a/contracts/examples/errors/payload_too_large.json b/contracts/examples/errors/payload_too_large.json new file mode 100644 index 0000000..6f7976a --- /dev/null +++ b/contracts/examples/errors/payload_too_large.json @@ -0,0 +1,5 @@ +{ + "code": "payload_too_large", + "message": "body exceeds 1 MiB", + "field": null +} diff --git a/contracts/examples/errors/protocol_unsupported.json b/contracts/examples/errors/protocol_unsupported.json new file mode 100644 index 0000000..6523e66 --- /dev/null +++ b/contracts/examples/errors/protocol_unsupported.json @@ -0,0 +1,5 @@ +{ + "code": "protocol_unsupported", + "message": "X-Kvitt-Protocol must be 1", + "field": null +} diff --git a/contracts/examples/errors/rate_limited.json b/contracts/examples/errors/rate_limited.json new file mode 100644 index 0000000..a69cb1d --- /dev/null +++ b/contracts/examples/errors/rate_limited.json @@ -0,0 +1,5 @@ +{ + "code": "rate_limited", + "message": "too many requests", + "field": null +} diff --git a/contracts/examples/errors/unauthorized.json b/contracts/examples/errors/unauthorized.json new file mode 100644 index 0000000..f60cc25 --- /dev/null +++ b/contracts/examples/errors/unauthorized.json @@ -0,0 +1,5 @@ +{ + "code": "unauthorized", + "message": "missing or unknown bearer token", + "field": null +} diff --git a/contracts/examples/errors/unavailable.json b/contracts/examples/errors/unavailable.json new file mode 100644 index 0000000..069c1b3 --- /dev/null +++ b/contracts/examples/errors/unavailable.json @@ -0,0 +1,5 @@ +{ + "code": "unavailable", + "message": "database busy", + "field": null +} diff --git a/contracts/examples/errors/unsupported_media_type.json b/contracts/examples/errors/unsupported_media_type.json new file mode 100644 index 0000000..f060f0a --- /dev/null +++ b/contracts/examples/errors/unsupported_media_type.json @@ -0,0 +1,5 @@ +{ + "code": "unsupported_media_type", + "message": "picture must be image/jpeg, image/png or image/webp", + "field": null +} diff --git a/contracts/examples/errors/validation_failed.json b/contracts/examples/errors/validation_failed.json new file mode 100644 index 0000000..2ae81c7 --- /dev/null +++ b/contracts/examples/errors/validation_failed.json @@ -0,0 +1,5 @@ +{ + "code": "validation_failed", + "message": "amount_ore must be between 1 and 1000000000", + "field": "amount_ore" +} diff --git a/contracts/examples/push/expense_created.json b/contracts/examples/push/expense_created.json new file mode 100644 index 0000000..b49fbf8 --- /dev/null +++ b/contracts/examples/push/expense_created.json @@ -0,0 +1,17 @@ +{ + "message": { + "token": "dGhpcyBpcyBhbiBleGFtcGxlIGZjbSB0b2tlbg", + "android": { + "priority": "high", + "ttl": "86400s" + }, + "data": { + "v": "1", + "type": "expense_created", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000001", + "actor_user_id": "0192a000-0000-7000-8000-000000000001", + "count": "1" + } + } +} diff --git a/contracts/examples/push/expense_deleted.json b/contracts/examples/push/expense_deleted.json new file mode 100644 index 0000000..ca68795 --- /dev/null +++ b/contracts/examples/push/expense_deleted.json @@ -0,0 +1,17 @@ +{ + "message": { + "token": "dGhpcyBpcyBhbiBleGFtcGxlIGZjbSB0b2tlbg", + "android": { + "priority": "high", + "ttl": "86400s" + }, + "data": { + "v": "1", + "type": "expense_deleted", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000001", + "actor_user_id": "0192a000-0000-7000-8000-000000000001", + "count": "1" + } + } +} diff --git a/contracts/examples/push/expense_updated.json b/contracts/examples/push/expense_updated.json new file mode 100644 index 0000000..abe04a0 --- /dev/null +++ b/contracts/examples/push/expense_updated.json @@ -0,0 +1,17 @@ +{ + "message": { + "token": "dGhpcyBpcyBhbiBleGFtcGxlIGZjbSB0b2tlbg", + "android": { + "priority": "high", + "ttl": "86400s" + }, + "data": { + "v": "1", + "type": "expense_updated", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000001", + "actor_user_id": "0192a000-0000-7000-8000-000000000002", + "count": "1" + } + } +} diff --git a/contracts/examples/push/recurring_expense_created.json b/contracts/examples/push/recurring_expense_created.json new file mode 100644 index 0000000..b747989 --- /dev/null +++ b/contracts/examples/push/recurring_expense_created.json @@ -0,0 +1,17 @@ +{ + "message": { + "token": "dGhpcyBpcyBhbiBleGFtcGxlIGZjbSB0b2tlbg", + "android": { + "priority": "high", + "ttl": "86400s" + }, + "data": { + "v": "1", + "type": "recurring_expense_created", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000003", + "actor_user_id": "0192a000-0000-7000-8000-000000000001", + "count": "1" + } + } +} diff --git a/contracts/examples/push/settlement_created.json b/contracts/examples/push/settlement_created.json new file mode 100644 index 0000000..3e587aa --- /dev/null +++ b/contracts/examples/push/settlement_created.json @@ -0,0 +1,17 @@ +{ + "message": { + "token": "dGhpcyBpcyBhbiBleGFtcGxlIGZjbSB0b2tlbg", + "android": { + "priority": "high", + "ttl": "86400s" + }, + "data": { + "v": "1", + "type": "settlement_created", + "entity_type": "settlement", + "entity_id": "0192a005-0000-7000-8000-000000000001", + "actor_user_id": "0192a000-0000-7000-8000-000000000002", + "count": "1" + } + } +} diff --git a/contracts/examples/push/settlement_deleted.json b/contracts/examples/push/settlement_deleted.json new file mode 100644 index 0000000..b5dee36 --- /dev/null +++ b/contracts/examples/push/settlement_deleted.json @@ -0,0 +1,17 @@ +{ + "message": { + "token": "dGhpcyBpcyBhbiBleGFtcGxlIGZjbSB0b2tlbg", + "android": { + "priority": "high", + "ttl": "86400s" + }, + "data": { + "v": "1", + "type": "settlement_deleted", + "entity_type": "settlement", + "entity_id": "0192a005-0000-7000-8000-000000000001", + "actor_user_id": "0192a000-0000-7000-8000-000000000001", + "count": "1" + } + } +} diff --git a/contracts/examples/push/settlement_updated.json b/contracts/examples/push/settlement_updated.json new file mode 100644 index 0000000..ec98ebb --- /dev/null +++ b/contracts/examples/push/settlement_updated.json @@ -0,0 +1,17 @@ +{ + "message": { + "token": "dGhpcyBpcyBhbiBleGFtcGxlIGZjbSB0b2tlbg", + "android": { + "priority": "high", + "ttl": "86400s" + }, + "data": { + "v": "1", + "type": "settlement_updated", + "entity_type": "settlement", + "entity_id": "0192a005-0000-7000-8000-000000000001", + "actor_user_id": "0192a000-0000-7000-8000-000000000002", + "count": "1" + } + } +} diff --git a/contracts/examples/push/sync_batch.json b/contracts/examples/push/sync_batch.json new file mode 100644 index 0000000..9a3918e --- /dev/null +++ b/contracts/examples/push/sync_batch.json @@ -0,0 +1,17 @@ +{ + "message": { + "token": "dGhpcyBpcyBhbiBleGFtcGxlIGZjbSB0b2tlbg", + "android": { + "priority": "high", + "ttl": "86400s" + }, + "data": { + "v": "1", + "type": "sync_batch", + "entity_type": "", + "entity_id": "", + "actor_user_id": "0192a000-0000-7000-8000-000000000002", + "count": "4" + } + } +} diff --git a/contracts/examples/sync/pull-empty-page.json b/contracts/examples/sync/pull-empty-page.json new file mode 100644 index 0000000..c56a67e --- /dev/null +++ b/contracts/examples/sync/pull-empty-page.json @@ -0,0 +1,14 @@ +{ + "epoch": "30b85fb529aac0016dc91650841005b9", + "since": 921, + "next_since": 921, + "has_more": false, + "server_time": "2026-10-25T12:00:05.000Z", + "users": [], + "categories": [], + "accounts": [], + "recurring_templates": [], + "expenses": [], + "settlements": [], + "audit_entries": [] +} diff --git a/contracts/examples/sync/pull-page.json b/contracts/examples/sync/pull-page.json new file mode 100644 index 0000000..289a9a9 --- /dev/null +++ b/contracts/examples/sync/pull-page.json @@ -0,0 +1,323 @@ +{ + "epoch": "30b85fb529aac0016dc91650841005b9", + "since": 0, + "next_since": 921, + "has_more": true, + "server_time": "2026-10-25T12:00:00.000Z", + "users": [ + { + "id": "0192a000-0000-7000-8000-000000000001", + "role": "admin", + "display_name": "Jacob", + "picture_version": null, + "created_at": "2026-10-17T06:12:40.512Z", + "updated_at": "2026-10-17T06:12:40.512Z", + "server_version": 11 + }, + { + "id": "0192a000-0000-7000-8000-000000000002", + "role": "member", + "display_name": "Anna", + "picture_version": 2, + "created_at": "2026-10-17T07:01:15.000Z", + "updated_at": "2026-10-17T07:05:00.250Z", + "server_version": 12 + } + ], + "categories": [ + { + "id": "01920000-0000-7000-8000-000000000001", + "default_key": "groceries", + "name": null, + "icon": "shopping_cart", + "color": "green", + "sort_order": 10, + "created_at": "2026-10-17T00:00:00.000Z", + "updated_at": "2026-10-17T00:00:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 1 + }, + { + "id": "01920000-0000-7000-8000-000000000002", + "default_key": "restaurants", + "name": null, + "icon": "restaurant", + "color": "orange", + "sort_order": 20, + "created_at": "2026-10-17T00:00:00.000Z", + "updated_at": "2026-10-17T00:00:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 2 + } + ], + "accounts": [ + { + "id": "0192a003-0000-7000-8000-000000000001", + "name": "Vardag", + "default_shares": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 5000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T07:10:00.000Z", + "updated_at": "2026-10-17T07:10:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 13 + } + ], + "recurring_templates": [ + { + "id": "0192a004-0000-7000-8000-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000003", + "amount_ore": 1250000, + "description": "Hyra", + "notes": "", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "ratio", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_bp": 6000, + "share_ore": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_bp": 4000, + "share_ore": null + } + ], + "day_of_month": 25, + "start_period": "2026-10", + "end_period": null, + "paused": false, + "last_generated_period": "2026-10", + "next_occurrence_date": "2026-11-25", + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-17T06:30:02.004Z", + "updated_at": "2026-10-25T05:00:00.031Z", + "deleted_at": null, + "server_version": 921 + } + ], + "expenses": [ + { + "id": "01929c5e-1111-7aaa-8bbb-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000001", + "amount_ore": 41200, + "description": "ICA Maxi", + "notes": "", + "expense_date": "2026-10-16", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "default", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ], + "recurring_template_id": null, + "recurring_period": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-16T17:02:11.123Z", + "updated_at": "2026-10-16T17:02:11.123Z", + "deleted_at": null, + "server_version": 913 + }, + { + "id": "01929c5e-1111-7aaa-8bbb-000000000002", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000002", + "amount_ore": 1001, + "description": "Fika", + "notes": "Kanelbullar\noch kaffe", + "expense_date": "2026-10-15", + "payer_user_id": "0192a000-0000-7000-8000-000000000002", + "split_mode": "exact", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 701, + "share_bp": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 300, + "share_bp": null + } + ], + "recurring_template_id": null, + "recurring_period": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000002", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000002", + "created_at": "2026-10-15T10:00:00.000Z", + "updated_at": "2026-10-15T10:00:00.000Z", + "deleted_at": null, + "server_version": 914 + }, + { + "id": "01929c5e-1111-7aaa-8bbb-000000000003", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000003", + "amount_ore": 1250000, + "description": "Hyra", + "notes": "", + "expense_date": "2026-10-25", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "ratio", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 750000, + "share_bp": 6000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 500000, + "share_bp": 4000 + } + ], + "recurring_template_id": "0192a004-0000-7000-8000-000000000001", + "recurring_period": "2026-10", + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-25T05:00:00.031Z", + "updated_at": "2026-10-25T05:00:00.031Z", + "deleted_at": null, + "server_version": 919 + } + ], + "settlements": [ + { + "id": "0192a005-0000-7000-8000-000000000001", + "from_user_id": "0192a000-0000-7000-8000-000000000002", + "to_user_id": "0192a000-0000-7000-8000-000000000001", + "amount_ore": 50000, + "settlement_date": "2026-10-16", + "note": "Swish", + "allocations": [ + { + "account_id": "0192a003-0000-7000-8000-000000000001", + "amount_ore": 30000 + }, + { + "account_id": "0192a003-0000-7000-8000-000000000002", + "amount_ore": 20000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000002", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000002", + "created_at": "2026-10-16T18:00:00.000Z", + "updated_at": "2026-10-16T18:00:00.000Z", + "deleted_at": null, + "server_version": 915 + } + ], + "audit_entries": [ + { + "id": "0192a006-0000-7000-8000-000000000001", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000001", + "action": "create", + "source": "app", + "actor_user_id": "0192a000-0000-7000-8000-000000000001", + "client_at": "2026-10-16T17:02:11.123Z", + "server_at": "2026-10-16T17:02:12.480Z", + "changes": [ + { + "field": "account_id", + "old": null, + "new": "0192a003-0000-7000-8000-000000000001" + }, + { + "field": "category_id", + "old": null, + "new": "01920000-0000-7000-8000-000000000001" + }, + { + "field": "payer_user_id", + "old": null, + "new": "0192a000-0000-7000-8000-000000000001" + }, + { + "field": "amount_ore", + "old": null, + "new": 41200 + }, + { + "field": "description", + "old": null, + "new": "ICA Maxi" + }, + { + "field": "notes", + "old": null, + "new": "" + }, + { + "field": "expense_date", + "old": null, + "new": "2026-10-16" + }, + { + "field": "split_mode", + "old": null, + "new": "default" + }, + { + "field": "splits", + "old": null, + "new": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ] + } + ], + "server_version": 916 + }, + { + "id": "0192a006-0000-7000-8000-000000000002", + "entity_type": "settlement", + "entity_id": "0192a005-0000-7000-8000-000000000001", + "action": "update", + "source": "app", + "actor_user_id": "0192a000-0000-7000-8000-000000000002", + "client_at": "2026-10-16T18:05:00.000Z", + "server_at": "2026-10-16T18:05:01.200Z", + "changes": [ + { + "field": "note", + "old": "", + "new": "Swish" + } + ], + "server_version": 917 + } + ] +} diff --git a/contracts/examples/sync/push-request-delete.json b/contracts/examples/sync/push-request-delete.json new file mode 100644 index 0000000..5d97ca5 --- /dev/null +++ b/contracts/examples/sync/push-request-delete.json @@ -0,0 +1,34 @@ +{ + "mutations": [ + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da0", + "entity_type": "expense", + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000002", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000002", + "amount_ore": 1001, + "description": "Fika", + "notes": "Kanelbullar\noch kaffe", + "expense_date": "2026-10-15", + "payer_user_id": "0192a000-0000-7000-8000-000000000002", + "split_mode": "exact", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 701, + "share_bp": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 300, + "share_bp": null + } + ], + "created_at": "2026-10-15T10:00:00.000Z", + "updated_at": "2026-10-17T08:00:00.000Z", + "deleted_at": "2026-10-17T08:00:00.000Z" + } + } + ] +} diff --git a/contracts/examples/sync/push-request-rejections.json b/contracts/examples/sync/push-request-rejections.json new file mode 100644 index 0000000..e6e0cb5 --- /dev/null +++ b/contracts/examples/sync/push-request-rejections.json @@ -0,0 +1,124 @@ +{ + "mutations": [ + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da3", + "entity_type": "expense", + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000002", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000002", + "amount_ore": 1001, + "description": "Fika på stan", + "notes": "Kanelbullar\noch kaffe", + "expense_date": "2026-10-15", + "payer_user_id": "0192a000-0000-7000-8000-000000000002", + "split_mode": "exact", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 701, + "share_bp": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 300, + "share_bp": null + } + ], + "created_at": "2026-10-15T10:00:00.000Z", + "updated_at": "2026-10-17T09:00:00.000Z", + "deleted_at": null + } + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da4", + "entity_type": "expense", + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000004", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000001", + "amount_ore": 41200, + "description": "ICA Maxi", + "notes": "", + "expense_date": "2026-10-16", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "default", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20000, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 21200, + "share_bp": 5000 + } + ], + "created_at": "2026-10-16T17:02:11.123Z", + "updated_at": "2026-10-16T17:02:11.123Z", + "deleted_at": null + } + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da1", + "entity_type": "expense", + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000005", + "account_id": "0192a003-0000-7000-8000-000000000002", + "category_id": "01920000-0000-7000-8000-000000000001", + "amount_ore": 41200, + "description": "ICA Maxi", + "notes": "", + "expense_date": "2026-10-16", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "default", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ], + "created_at": "2026-10-16T17:02:11.123Z", + "updated_at": "2026-10-16T17:02:11.123Z", + "deleted_at": null + } + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da2", + "entity_type": "expense", + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000006", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000001", + "amount_ore": 41200, + "description": "ICA Maxi", + "notes": "", + "expense_date": "2026-10-16", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "default", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ], + "created_at": "2026-10-18T12:00:00.000Z", + "updated_at": "2026-10-18T12:00:00.000Z", + "deleted_at": null + } + } + ] +} diff --git a/contracts/examples/sync/push-request.json b/contracts/examples/sync/push-request.json new file mode 100644 index 0000000..96a1af4 --- /dev/null +++ b/contracts/examples/sync/push-request.json @@ -0,0 +1,59 @@ +{ + "mutations": [ + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8d9e", + "entity_type": "expense", + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000001", + "amount_ore": 41200, + "description": "ICA Maxi", + "notes": "", + "expense_date": "2026-10-16", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "default", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ], + "created_at": "2026-10-16T17:02:11.123Z", + "updated_at": "2026-10-16T17:02:11.123Z", + "deleted_at": null + } + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8d9f", + "entity_type": "settlement", + "entity": { + "id": "0192a005-0000-7000-8000-000000000001", + "from_user_id": "0192a000-0000-7000-8000-000000000002", + "to_user_id": "0192a000-0000-7000-8000-000000000001", + "amount_ore": 50000, + "settlement_date": "2026-10-16", + "note": "Swish", + "allocations": [ + { + "account_id": "0192a003-0000-7000-8000-000000000001", + "amount_ore": 30000 + }, + { + "account_id": "0192a003-0000-7000-8000-000000000002", + "amount_ore": 20000 + } + ], + "created_at": "2026-10-16T18:00:00.000Z", + "updated_at": "2026-10-16T18:00:00.000Z", + "deleted_at": null + } + } + ] +} diff --git a/contracts/examples/sync/push-response-delete.json b/contracts/examples/sync/push-response-delete.json new file mode 100644 index 0000000..86b24a3 --- /dev/null +++ b/contracts/examples/sync/push-response-delete.json @@ -0,0 +1,43 @@ +{ + "server_time": "2026-10-17T08:00:01.000Z", + "results": [ + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da0", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000002", + "status": "accepted", + "server_version": 925, + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000002", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000002", + "amount_ore": 1001, + "description": "Fika", + "notes": "Kanelbullar\noch kaffe", + "expense_date": "2026-10-15", + "payer_user_id": "0192a000-0000-7000-8000-000000000002", + "split_mode": "exact", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 701, + "share_bp": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 300, + "share_bp": null + } + ], + "recurring_template_id": null, + "recurring_period": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000002", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-15T10:00:00.000Z", + "updated_at": "2026-10-17T08:00:00.000Z", + "deleted_at": "2026-10-17T08:00:00.000Z", + "server_version": 925 + } + } + ] +} diff --git a/contracts/examples/sync/push-response-rejections.json b/contracts/examples/sync/push-response-rejections.json new file mode 100644 index 0000000..48eaea3 --- /dev/null +++ b/contracts/examples/sync/push-response-rejections.json @@ -0,0 +1,71 @@ +{ + "server_time": "2026-10-17T12:00:00.000Z", + "results": [ + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da3", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000002", + "status": "rejected", + "reason": "deleted", + "message": "the row is deleted", + "current": { + "id": "01929c5e-1111-7aaa-8bbb-000000000002", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000002", + "amount_ore": 1001, + "description": "Fika", + "notes": "Kanelbullar\noch kaffe", + "expense_date": "2026-10-15", + "payer_user_id": "0192a000-0000-7000-8000-000000000002", + "split_mode": "exact", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 701, + "share_bp": null + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 300, + "share_bp": null + } + ], + "recurring_template_id": null, + "recurring_period": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000002", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-15T10:00:00.000Z", + "updated_at": "2026-10-17T08:00:00.000Z", + "deleted_at": "2026-10-17T08:00:00.000Z", + "server_version": 925 + } + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da4", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000004", + "status": "rejected", + "reason": "validation_failed", + "message": "share_ore does not match the split rule", + "current": null + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da1", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000005", + "status": "rejected", + "reason": "reference_not_found", + "message": "account_id", + "current": null + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8da2", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000006", + "status": "rejected", + "reason": "clock_skew", + "message": "updated_at is more than 10 minutes ahead of server time", + "current": null + } + ] +} diff --git a/contracts/examples/sync/push-response.json b/contracts/examples/sync/push-response.json new file mode 100644 index 0000000..396dfc9 --- /dev/null +++ b/contracts/examples/sync/push-response.json @@ -0,0 +1,75 @@ +{ + "server_time": "2026-10-17T12:00:00.000Z", + "results": [ + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8d9e", + "entity_type": "expense", + "entity_id": "01929c5e-1111-7aaa-8bbb-000000000001", + "status": "accepted", + "server_version": 913, + "entity": { + "id": "01929c5e-1111-7aaa-8bbb-000000000001", + "account_id": "0192a003-0000-7000-8000-000000000001", + "category_id": "01920000-0000-7000-8000-000000000001", + "amount_ore": 41200, + "description": "ICA Maxi", + "notes": "", + "expense_date": "2026-10-16", + "payer_user_id": "0192a000-0000-7000-8000-000000000001", + "split_mode": "default", + "splits": [ + { + "user_id": "0192a000-0000-7000-8000-000000000001", + "share_ore": 20600, + "share_bp": 5000 + }, + { + "user_id": "0192a000-0000-7000-8000-000000000002", + "share_ore": 20600, + "share_bp": 5000 + } + ], + "recurring_template_id": null, + "recurring_period": null, + "created_by_user_id": "0192a000-0000-7000-8000-000000000001", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000001", + "created_at": "2026-10-16T17:02:11.123Z", + "updated_at": "2026-10-16T17:02:11.123Z", + "deleted_at": null, + "server_version": 913 + } + }, + { + "mutation_id": "01929c5e-6c1f-7d3a-9b2e-4f5a6b7c8d9f", + "entity_type": "settlement", + "entity_id": "0192a005-0000-7000-8000-000000000001", + "status": "rejected", + "reason": "stale", + "message": "a newer version exists", + "current": { + "id": "0192a005-0000-7000-8000-000000000001", + "from_user_id": "0192a000-0000-7000-8000-000000000002", + "to_user_id": "0192a000-0000-7000-8000-000000000001", + "amount_ore": 50000, + "settlement_date": "2026-10-16", + "note": "Swish okt", + "allocations": [ + { + "account_id": "0192a003-0000-7000-8000-000000000001", + "amount_ore": 30000 + }, + { + "account_id": "0192a003-0000-7000-8000-000000000002", + "amount_ore": 20000 + } + ], + "created_by_user_id": "0192a000-0000-7000-8000-000000000002", + "updated_by_user_id": "0192a000-0000-7000-8000-000000000002", + "created_at": "2026-10-16T18:00:00.000Z", + "updated_at": "2026-10-16T19:00:00.000Z", + "deleted_at": null, + "server_version": 918 + } + } + ] +} diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml new file mode 100644 index 0000000..d729d48 --- /dev/null +++ b/contracts/openapi.yaml @@ -0,0 +1,2482 @@ +# Kvitt HTTP API, written by hand from SPEC v0.1 §7 and §14.3 (milestone M1). Schema version 1, sync protocol 1. +# SPEC §14.3 is binding; a change to this file follows the contract-change rule of SPEC §14.8. +# Value limits (lengths, ranges) are not encoded as JSON Schema constraints: a value outside SPEC §9.6 is +# 422 validation_failed, not 400 invalid_request (SPEC §14.3.1), so the limits stay in SPEC §9.6. +openapi: 3.1.0 +info: + title: Kvitt + version: '1' + description: | + Shared-expense server for a two-person household (SPEC.md). Money is integer öre, timestamps are + RFC 3339 UTC with millisecond precision (`2026-10-17T06:00:00.000Z`), ids are lowercase UUIDv7 strings. + + Common rules (SPEC §14.3.1): + * Every JSON body is `application/json; charset=utf-8`. + * Unknown JSON fields in requests are ignored. Missing required fields or wrong types: 400 `invalid_request`. + Values outside SPEC §9.6: 422 `validation_failed` with `field`. + * Optional fields in responses are present with `null`, never omitted. + * `PATCH` bodies: every field optional; absent = unchanged; at least one field required (`invalid_request`). + * Path ids that are not a UUID: 404 `not_found`. + * Authenticated endpoints without `X-Kvitt-Protocol: 1`: 426 `protocol_unsupported`. + * Every response carries `X-Kvitt-Server-Version`. + * Besides the codes listed per operation, authenticated operations can return `unauthorized`, `device_revoked`, + `protocol_unsupported`, `rate_limited`, `internal`, `unavailable`; unauthenticated ones `rate_limited`, + `internal`, `unavailable`. `not_implemented` (501) exists only during milestones M2a to M4. +servers: + - url: https://icehack.asuscomm.com:8443 +security: + - device: [] + +paths: + /health: + get: + operationId: getHealth + summary: E-1 Liveness and DB check + security: [] + parameters: + - $ref: '#/components/parameters/ProtocolOptional' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: Server and database are up. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Health' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + description: | + The database check (`SELECT 1`) failed: the `Health` shape with `status` `degraded` and `db_ok` false. + An `Error` with code `unavailable` when the server is shutting down. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + # TODO(spec): §14.3.4 E-1 gives 503 the Health shape; §14.3.2 gives every endpoint 503 `unavailable` (Error shape). + oneOf: + - $ref: '#/components/schemas/Health' + - $ref: '#/components/schemas/Error' + + /setup/status: + get: + operationId: getSetupStatus + summary: E-2 Whether the household has been bootstrapped + security: [] + parameters: + - $ref: '#/components/parameters/ProtocolOptional' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: '`initialized` is true once the admin_setup invite has been accepted (AC-1.5).' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/SetupStatus' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /invites/accept: + post: + operationId: acceptInvite + summary: E-3 Pair a device with an invite code (all three invite kinds) + security: [] + parameters: + - $ref: '#/components/parameters/ProtocolOptional' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/AcceptInviteRequest' + responses: + '200': + description: Paired. The token is shown only here. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/AcceptInviteResponse' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request` (also a missing `display_name` for admin_setup and partner), `invite_invalid`.' + '409': + $ref: '#/components/responses/Conflict' + description: '`household_full`.' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`validation_failed`.' + '429': + $ref: '#/components/responses/RateLimited' + description: '`rate_limited`, also after more than 10 failed accepts from one IP within 60 minutes (AC-2.12).' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /i/{code}: + get: + operationId: getInviteLanding + summary: E-4 Invite landing page (HTML) + description: | + HTML per AC-2.5 for any `code` of 1 to 32 characters; validity is not checked. Swedish when + `Accept-Language` prefers `sv`, English otherwise. + security: [] + parameters: + - name: code + in: path + required: true + schema: + type: string + - $ref: '#/components/parameters/ProtocolOptional' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: Landing page. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + text/html; charset=utf-8: + schema: + type: string + '404': + description: '`code` not 1 to 32 characters: an HTML 404 page.' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + text/html; charset=utf-8: + schema: + type: string + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /apk/latest: + get: + operationId: getApkLatest + summary: E-5 Latest APK metadata + security: [] + parameters: + - $ref: '#/components/parameters/ProtocolOptional' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The content of `latest.json` (SPEC §10.6), read on every request. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ApkLatest' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found` (nothing published).' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /apk/files/{file_name}: + get: + operationId: getApkFile + summary: E-6 APK download + security: [] + parameters: + - name: file_name + in: path + required: true + description: Served only when it matches `^kvitt-[0-9]+\.apk$` and exists (SPEC §10.6). + schema: + type: string + - $ref: '#/components/parameters/ProtocolOptional' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The APK bytes. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + Content-Length: + required: true + schema: + type: integer + content: + application/vnd.android.package-archive: + schema: + type: string + contentMediaType: application/vnd.android.package-archive + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found` (unknown APK file).' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /me: + get: + operationId: getMe + summary: E-7 Current user, device and household users + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The caller's user and device. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/MeResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + patch: + operationId: patchMe + summary: E-8 Change own display name + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PatchMeRequest' + responses: + '200': + description: The updated user. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`validation_failed`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /me/picture: + put: + operationId: putMePicture + summary: E-9 Upload own picture + description: JPEG, PNG or WebP up to 5 MiB and 8000 × 8000 px; processed per AC-4.4. + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + image/jpeg: + schema: + type: string + contentMediaType: image/jpeg + image/png: + schema: + type: string + contentMediaType: image/png + image/webp: + schema: + type: string + contentMediaType: image/webp + responses: + '200': + description: The user with the new `picture_version`. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/User' + '401': + $ref: '#/components/responses/Unauthorized' + '413': + $ref: '#/components/responses/PayloadTooLarge' + description: '`payload_too_large` (over 5 MiB).' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + description: '`unsupported_media_type`.' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`image_invalid`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + delete: + operationId: deleteMePicture + summary: E-10 Remove own picture + description: Idempotent. + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The user with `picture_version` null. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/User' + '401': + $ref: '#/components/responses/Unauthorized' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /users/{user_id}/picture: + get: + operationId: getUserPicture + summary: E-11 Download a user's picture + parameters: + - $ref: '#/components/parameters/UserId' + - name: size + in: query + required: false + description: Other values are `invalid_request`. + schema: + type: integer + enum: [128, 512] + default: 512 + - name: v + in: query + required: false + description: The `picture_version`; a cache key only, ignored by the server. + schema: + type: integer + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The square JPEG. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + ETag: + description: '`""`' + required: true + schema: + type: string + Cache-Control: + description: '`private, max-age=31536000, immutable`' + required: true + schema: + type: string + content: + image/jpeg: + schema: + type: string + contentMediaType: image/jpeg + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request` (bad `size`).' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found` (unknown user or no picture).' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /invites: + post: + operationId: createInvite + summary: E-12 Create a partner or device invite (admin) + description: Creating a `partner` invite cancels any pending one (AC-2.1). + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/CreateInviteRequest' + responses: + '201': + description: The invite with `code` (12 characters, no hyphens) and `link` (`/i/`). + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Invite' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + description: '`forbidden` (caller is not the admin).' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found` (target user).' + '409': + $ref: '#/components/responses/Conflict' + description: '`household_full`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + get: + operationId: listInvites + summary: E-13 List pending invites (admin) + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: Pending invites only (not accepted, not cancelled, not expired), `code` and `link` null. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/InviteList' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + description: '`forbidden`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /invites/{invite_id}: + delete: + operationId: cancelInvite + summary: E-14 Cancel an invite (admin) + description: Idempotent for an already cancelled invite. + parameters: + - $ref: '#/components/parameters/InviteId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '204': + description: Cancelled. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + description: '`forbidden`.' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /devices: + get: + operationId: listDevices + summary: E-15 List devices (admin all, member own) + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: Ordered by `created_at`, revoked devices included (with `revoked_at`). + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/DeviceList' + '401': + $ref: '#/components/responses/Unauthorized' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /devices/{device_id}: + delete: + operationId: revokeDevice + summary: E-16 Revoke a device + description: Revoking an already revoked device is 204. Admin any device except the current one; member own devices except the current one (SPEC §8.4). + parameters: + - $ref: '#/components/parameters/DeviceId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '204': + description: Revoked. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + description: '`forbidden`.' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '409': + $ref: '#/components/responses/Conflict' + description: '`cannot_revoke_current_device`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /devices/current/push-token: + put: + operationId: putPushToken + summary: E-17 Set or clear this device's FCM token + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PushTokenRequest' + responses: + '204': + description: Stored (or cleared). + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /accounts: + post: + operationId: createAccount + summary: E-18 Create account + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/CreateAccountRequest' + responses: + '201': + description: The new account. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Account' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' + description: '`name_taken`, `household_incomplete`.' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`validation_failed`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /accounts/{account_id}: + patch: + operationId: patchAccount + summary: E-19 Rename, change default ratio + parameters: + - $ref: '#/components/parameters/AccountId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PatchAccountRequest' + responses: + '200': + description: The updated account. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Account' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '409': + $ref: '#/components/responses/Conflict' + description: '`name_taken`.' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`validation_failed`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + delete: + operationId: deleteAccount + summary: E-22 Soft delete when empty + description: Refused while a non-deleted expense, an allocation of a non-deleted settlement or a non-deleted recurring template references the account (AC-6.5). + parameters: + - $ref: '#/components/parameters/AccountId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The tombstone. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Account' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '409': + $ref: '#/components/responses/Conflict' + description: '`account_not_empty`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /accounts/{account_id}/archive: + post: + operationId: archiveAccount + summary: E-20 Archive account + description: No body. Idempotent. + parameters: + - $ref: '#/components/parameters/AccountId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The account. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Account' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /accounts/{account_id}/unarchive: + post: + operationId: unarchiveAccount + summary: E-21 Unarchive account + description: No body. Idempotent. + parameters: + - $ref: '#/components/parameters/AccountId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The account. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Account' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /categories: + post: + operationId: createCategory + summary: E-23 Create category + description: New categories get `sort_order` = current maximum + 10 (AC-7.3). `icon` and `color` are tokens of SPEC §9.7. + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/CreateCategoryRequest' + responses: + '201': + description: The new category. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Category' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`validation_failed`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /categories/{category_id}: + patch: + operationId: patchCategory + summary: E-24 Rename, icon, colour, order, reset name + description: | + `name: null` resets a default category's name. Reordering several categories is one PATCH per category whose + `sort_order` changes. + parameters: + - $ref: '#/components/parameters/CategoryId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PatchCategoryRequest' + responses: + '200': + description: The updated category. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Category' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '409': + $ref: '#/components/responses/Conflict' + description: '`category_is_default` (`name: null` on a non-default category).' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`validation_failed`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + delete: + operationId: deleteCategory + summary: E-27 Soft delete when unused and not default + parameters: + - $ref: '#/components/parameters/CategoryId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The tombstone. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Category' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '409': + $ref: '#/components/responses/Conflict' + description: '`category_in_use`, `category_is_default`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /categories/{category_id}/archive: + post: + operationId: archiveCategory + summary: E-25 Archive category + description: No body. + parameters: + - $ref: '#/components/parameters/CategoryId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The category. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Category' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /categories/{category_id}/unarchive: + post: + operationId: unarchiveCategory + summary: E-26 Unarchive category + description: No body. + parameters: + - $ref: '#/components/parameters/CategoryId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The category. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Category' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /recurring-templates: + post: + operationId: createRecurringTemplate + summary: E-28 Create recurring template + description: If a period is already due (SPEC §9.5), the server generates it before responding. + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/CreateRecurringTemplateRequest' + responses: + '201': + description: The new template. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/RecurringTemplate' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' + description: '`household_incomplete`.' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`validation_failed` (also for an unknown or deleted `account_id` or `category_id`, with `field`).' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /recurring-templates/{template_id}: + patch: + operationId: patchRecurringTemplate + summary: E-29 Edit, pause, resume + description: Any subset of the E-28 fields; `split_mode` and `splits` must be sent together. + parameters: + - $ref: '#/components/parameters/TemplateId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/PatchRecurringTemplateRequest' + responses: + '200': + description: The updated template. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/RecurringTemplate' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`validation_failed`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + delete: + operationId: deleteRecurringTemplate + summary: E-30 Soft delete recurring template + parameters: + - $ref: '#/components/parameters/TemplateId' + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: The tombstone. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/RecurringTemplate' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + description: '`not_found`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /sync: + get: + operationId: syncPull + summary: E-31 Pull changes since a cursor + description: SPEC §14.4.2. + parameters: + - name: since + in: query + # TODO(spec): §14.4.2 does not say whether `since` may be omitted; required here (the phone's cursor starts at 0). + required: true + description: The highest `server_version` the phone has applied (0 initially). + schema: + type: integer + format: int64 + - name: limit + in: query + required: false + description: Rows per page, 1 to 1000 (`invalid_request` otherwise). + schema: + type: integer + default: 500 + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + responses: + '200': + description: One page. Every array is present (possibly empty) and sorted by `server_version`. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/SyncPullResponse' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' + description: '`cursor_ahead`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + post: + operationId: syncPush + summary: E-32 Push local expense and settlement changes + description: SPEC §14.4.3 and §14.4.4. 1 to 100 mutations, body at most 1 MiB. One result per mutation, in request order. + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/SyncPushRequest' + responses: + '200': + description: Per-mutation results. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/SyncPushResponse' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' + description: '`household_incomplete` (whole request, before any mutation).' + '413': + $ref: '#/components/responses/PayloadTooLarge' + description: '`payload_too_large`.' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`batch_too_large` (more than 100 mutations).' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + + /settlements/proposal: + post: + operationId: proposeSettlement + summary: E-33 Server-side allocation proposal + description: Computed with `kvitt-domain::propose_allocation` (SPEC §9.4) over the server's non-deleted rows. + parameters: + - $ref: '#/components/parameters/Protocol' + - $ref: '#/components/parameters/AppVersion' + - $ref: '#/components/parameters/AcceptLanguage' + requestBody: + required: true + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ProposalRequest' + responses: + '200': + description: The proposal. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ProposalResponse' + '400': + $ref: '#/components/responses/BadRequest' + description: '`invalid_request`.' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' + description: '`no_accounts`.' + '422': + $ref: '#/components/responses/Unprocessable' + description: '`validation_failed`.' + '426': + $ref: '#/components/responses/ProtocolUnsupported' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/Internal' + '503': + $ref: '#/components/responses/Unavailable' + +components: + securitySchemes: + device: + type: http + scheme: bearer + description: | + `Authorization: Bearer kvt_<43 base64url characters>` (SPEC §8.2). Missing or unknown token: 401 + `unauthorized`; revoked device: 401 `device_revoked`. Admin-only operations (E-12, E-13, E-14) return 403 + `forbidden` for a member (SPEC §8.4). + + parameters: + Protocol: + name: X-Kvitt-Protocol + in: header + required: true + description: Sync protocol version. Missing or other than `1` on an authenticated endpoint is 426 `protocol_unsupported`. + schema: + type: string + enum: ['1'] + ProtocolOptional: + name: X-Kvitt-Protocol + in: header + required: false + description: Sent by the app on every call; not enforced on unauthenticated endpoints. + schema: + type: string + enum: ['1'] + AppVersion: + name: X-Kvitt-App-Version + in: header + required: false + description: The app's `versionCode`, sent by the app on every call. + schema: + type: integer + AcceptLanguage: + name: Accept-Language + in: header + required: false + description: The device locale. + schema: + type: string + UserId: + name: user_id + in: path + required: true + description: Not a UUID is 404 `not_found`. + schema: + $ref: '#/components/schemas/Uuid' + InviteId: + name: invite_id + in: path + required: true + description: Not a UUID is 404 `not_found`. + schema: + $ref: '#/components/schemas/Uuid' + DeviceId: + name: device_id + in: path + required: true + description: Not a UUID is 404 `not_found`. + schema: + $ref: '#/components/schemas/Uuid' + AccountId: + name: account_id + in: path + required: true + description: Not a UUID is 404 `not_found`. + schema: + $ref: '#/components/schemas/Uuid' + CategoryId: + name: category_id + in: path + required: true + description: Not a UUID is 404 `not_found`. + schema: + $ref: '#/components/schemas/Uuid' + TemplateId: + name: template_id + in: path + required: true + description: Not a UUID is 404 `not_found`. + schema: + $ref: '#/components/schemas/Uuid' + + headers: + ServerVersion: + description: The server crate version. + required: true + schema: + type: string + RetryAfter: + description: Seconds until the client may retry. + required: true + schema: + type: integer + + responses: + BadRequest: + description: '`invalid_request` or `invite_invalid`.' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + Unauthorized: + description: '`unauthorized` or `device_revoked`.' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: '`forbidden`.' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: '`not_found`.' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + Conflict: + description: A 409 error code of SPEC §14.3.2. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + PayloadTooLarge: + description: '`payload_too_large`.' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + UnsupportedMediaType: + description: '`unsupported_media_type`.' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + Unprocessable: + description: A 422 error code of SPEC §14.3.2. + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + ProtocolUnsupported: + description: '`protocol_unsupported`.' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + RateLimited: + description: '`rate_limited` (SPEC §8.6).' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + Retry-After: + $ref: '#/components/headers/RetryAfter' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + Internal: + description: '`internal` (logged with a request id, returned in `message`).' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + NotImplemented: + description: '`not_implemented` (route registered but not yet implemented; only during milestones M2a to M4).' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + Unavailable: + description: '`unavailable` (database busy beyond `busy_timeout`, or server shutting down).' + headers: + X-Kvitt-Server-Version: + $ref: '#/components/headers/ServerVersion' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Error' + + schemas: + # ---- primitives (SPEC conventions, §14.3.3) ---- + Uuid: + type: string + format: uuid + description: Lowercase, hyphenated, 36 characters (UUIDv7). + pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + Timestamp: + type: string + format: date-time + description: RFC 3339 UTC, millisecond precision, literal Z. + pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$' + Date: + type: string + format: date + pattern: '^\d{4}-\d{2}-\d{2}$' + Period: + type: string + description: A month, YYYY-MM. + pattern: '^\d{4}-(0[1-9]|1[0-2])$' + Ore: + type: integer + format: int64 + description: Money in öre (1/100 SEK). + ServerVersionNumber: + type: integer + format: int64 + description: From the one global, strictly increasing sequence (SPEC §5.2). + + # ---- enums ---- + Role: + type: string + enum: [admin, member] + InviteKind: + type: string + enum: [admin_setup, partner, device] + DefaultCategoryKey: + type: string + enum: [groceries, restaurants, home, utilities, transport, travel, entertainment, health, shopping, other] + SplitMode: + type: string + enum: [default, ratio, exact, full] + EntityType: + type: string + enum: [expense, settlement] + AuditAction: + type: string + enum: [create, update, delete] + AuditSource: + type: string + enum: [app, recurring, import] + ErrorCode: + type: string + description: Exhaustive (SPEC §14.3.2). The app maps `code` (and `field`) to localised strings. + enum: + - invalid_request + - invite_invalid + - unauthorized + - device_revoked + - forbidden + - not_found + - household_full + - household_incomplete + - name_taken + - account_not_empty + - category_in_use + - category_is_default + - cannot_revoke_current_device + - no_accounts + - cursor_ahead + - payload_too_large + - unsupported_media_type + - validation_failed + - image_invalid + - batch_too_large + - protocol_unsupported + - rate_limited + - internal + - not_implemented + - unavailable + RejectionReason: + type: string + description: Exhaustive (SPEC §14.4.6). + enum: + - stale + - deleted + - validation_failed + - reference_not_found + - clock_skew + + # ---- wire objects (SPEC §14.3.3) ---- + User: + type: object + required: [id, role, display_name, picture_version, created_at, updated_at, server_version] + properties: + id: + $ref: '#/components/schemas/Uuid' + role: + $ref: '#/components/schemas/Role' + display_name: + type: string + picture_version: + type: [integer, 'null'] + created_at: + $ref: '#/components/schemas/Timestamp' + updated_at: + $ref: '#/components/schemas/Timestamp' + server_version: + $ref: '#/components/schemas/ServerVersionNumber' + Device: + type: object + description: Only in /me, /devices and /invites/accept. + required: [id, user_id, name, app_version_code, created_at, last_seen_at, revoked_at, is_current] + properties: + id: + $ref: '#/components/schemas/Uuid' + user_id: + $ref: '#/components/schemas/Uuid' + name: + type: string + app_version_code: + type: [integer, 'null'] + created_at: + $ref: '#/components/schemas/Timestamp' + last_seen_at: + $ref: '#/components/schemas/Timestamp' + revoked_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + is_current: + type: boolean + Invite: + type: object + description: '`code` and `link` only in the POST /invites response; null in GET /invites.' + required: [id, kind, target_user_id, code, link, created_at, expires_at] + properties: + id: + $ref: '#/components/schemas/Uuid' + kind: + $ref: '#/components/schemas/InviteKind' + target_user_id: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + code: + type: [string, 'null'] + link: + type: [string, 'null'] + created_at: + $ref: '#/components/schemas/Timestamp' + expires_at: + $ref: '#/components/schemas/Timestamp' + Share: + type: object + description: A share of an account default. + required: [user_id, share_bp] + properties: + user_id: + $ref: '#/components/schemas/Uuid' + share_bp: + type: integer + Account: + type: object + required: [id, name, default_shares, created_by_user_id, created_at, updated_at, archived_at, deleted_at, server_version] + properties: + id: + $ref: '#/components/schemas/Uuid' + name: + type: string + default_shares: + type: array + description: Sorted by `user_id`. + minItems: 2 + maxItems: 2 + items: + $ref: '#/components/schemas/Share' + created_by_user_id: + $ref: '#/components/schemas/Uuid' + created_at: + $ref: '#/components/schemas/Timestamp' + updated_at: + $ref: '#/components/schemas/Timestamp' + archived_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + deleted_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + server_version: + $ref: '#/components/schemas/ServerVersionNumber' + Category: + type: object + required: [id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version] + properties: + id: + $ref: '#/components/schemas/Uuid' + default_key: + oneOf: + - $ref: '#/components/schemas/DefaultCategoryKey' + - type: 'null' + name: + type: [string, 'null'] + icon: + type: string + description: An icon token of SPEC §9.7. + color: + type: string + description: A colour token of SPEC §9.7. + sort_order: + type: integer + created_at: + $ref: '#/components/schemas/Timestamp' + updated_at: + $ref: '#/components/schemas/Timestamp' + archived_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + deleted_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + server_version: + $ref: '#/components/schemas/ServerVersionNumber' + Split: + type: object + description: A split of an expense; `share_bp` null for mode exact. + required: [user_id, share_ore, share_bp] + properties: + user_id: + $ref: '#/components/schemas/Uuid' + share_ore: + $ref: '#/components/schemas/Ore' + share_bp: + type: [integer, 'null'] + TemplateSplit: + type: object + description: Exactly one of `share_bp` / `share_ore` non-null. + required: [user_id, share_bp, share_ore] + properties: + user_id: + $ref: '#/components/schemas/Uuid' + share_bp: + type: [integer, 'null'] + share_ore: + type: [integer, 'null'] + format: int64 + Expense: + type: object + required: [id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, split_mode, splits, + recurring_template_id, recurring_period, created_by_user_id, updated_by_user_id, created_at, updated_at, + deleted_at, server_version] + properties: + id: + $ref: '#/components/schemas/Uuid' + account_id: + $ref: '#/components/schemas/Uuid' + category_id: + $ref: '#/components/schemas/Uuid' + amount_ore: + $ref: '#/components/schemas/Ore' + description: + type: string + notes: + type: string + expense_date: + $ref: '#/components/schemas/Date' + payer_user_id: + $ref: '#/components/schemas/Uuid' + split_mode: + $ref: '#/components/schemas/SplitMode' + splits: + type: array + description: Sorted by `user_id`. + minItems: 2 + maxItems: 2 + items: + $ref: '#/components/schemas/Split' + recurring_template_id: + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + recurring_period: + oneOf: + - $ref: '#/components/schemas/Period' + - type: 'null' + created_by_user_id: + $ref: '#/components/schemas/Uuid' + updated_by_user_id: + $ref: '#/components/schemas/Uuid' + created_at: + $ref: '#/components/schemas/Timestamp' + updated_at: + $ref: '#/components/schemas/Timestamp' + deleted_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + server_version: + $ref: '#/components/schemas/ServerVersionNumber' + Allocation: + type: object + required: [account_id, amount_ore] + properties: + account_id: + $ref: '#/components/schemas/Uuid' + amount_ore: + $ref: '#/components/schemas/Ore' + Settlement: + type: object + required: [id, from_user_id, to_user_id, amount_ore, settlement_date, note, allocations, created_by_user_id, + updated_by_user_id, created_at, updated_at, deleted_at, server_version] + properties: + id: + $ref: '#/components/schemas/Uuid' + from_user_id: + $ref: '#/components/schemas/Uuid' + to_user_id: + $ref: '#/components/schemas/Uuid' + amount_ore: + $ref: '#/components/schemas/Ore' + settlement_date: + $ref: '#/components/schemas/Date' + note: + type: string + allocations: + type: array + description: Sorted by `account_id`. + items: + $ref: '#/components/schemas/Allocation' + created_by_user_id: + $ref: '#/components/schemas/Uuid' + updated_by_user_id: + $ref: '#/components/schemas/Uuid' + created_at: + $ref: '#/components/schemas/Timestamp' + updated_at: + $ref: '#/components/schemas/Timestamp' + deleted_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + server_version: + $ref: '#/components/schemas/ServerVersionNumber' + RecurringTemplate: + type: object + required: [id, account_id, category_id, amount_ore, description, notes, payer_user_id, split_mode, splits, day_of_month, + start_period, end_period, paused, last_generated_period, next_occurrence_date, created_by_user_id, + updated_by_user_id, created_at, updated_at, deleted_at, server_version] + properties: + id: + $ref: '#/components/schemas/Uuid' + account_id: + $ref: '#/components/schemas/Uuid' + category_id: + $ref: '#/components/schemas/Uuid' + amount_ore: + $ref: '#/components/schemas/Ore' + description: + type: string + notes: + type: string + payer_user_id: + $ref: '#/components/schemas/Uuid' + split_mode: + $ref: '#/components/schemas/SplitMode' + splits: + type: array + description: Sorted by `user_id`; empty for mode default. + items: + $ref: '#/components/schemas/TemplateSplit' + day_of_month: + type: integer + start_period: + $ref: '#/components/schemas/Period' + end_period: + oneOf: + - $ref: '#/components/schemas/Period' + - type: 'null' + paused: + type: boolean + last_generated_period: + oneOf: + - $ref: '#/components/schemas/Period' + - type: 'null' + next_occurrence_date: + description: Computed by the server (SPEC §9.5); null when paused, deleted or past `end_period`. + oneOf: + - $ref: '#/components/schemas/Date' + - type: 'null' + created_by_user_id: + $ref: '#/components/schemas/Uuid' + updated_by_user_id: + $ref: '#/components/schemas/Uuid' + created_at: + $ref: '#/components/schemas/Timestamp' + updated_at: + $ref: '#/components/schemas/Timestamp' + deleted_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + server_version: + $ref: '#/components/schemas/ServerVersionNumber' + AuditChange: + type: object + description: One field of the audit diff (SPEC §14.4.7). `old` and `new` are JSON values of the field's type, or null. + required: [field, old, new] + properties: + field: + type: string + old: {} + new: {} + AuditEntry: + type: object + required: [id, entity_type, entity_id, action, source, actor_user_id, client_at, server_at, changes, server_version] + properties: + id: + $ref: '#/components/schemas/Uuid' + entity_type: + $ref: '#/components/schemas/EntityType' + entity_id: + $ref: '#/components/schemas/Uuid' + action: + $ref: '#/components/schemas/AuditAction' + source: + $ref: '#/components/schemas/AuditSource' + actor_user_id: + $ref: '#/components/schemas/Uuid' + client_at: + $ref: '#/components/schemas/Timestamp' + server_at: + $ref: '#/components/schemas/Timestamp' + changes: + type: array + items: + $ref: '#/components/schemas/AuditChange' + server_version: + $ref: '#/components/schemas/ServerVersionNumber' + Error: + type: object + description: '`message` is English developer text, never shown to users. `field` is the JSON key concerned or null.' + required: [code, message, field] + properties: + code: + $ref: '#/components/schemas/ErrorCode' + message: + type: string + field: + type: [string, 'null'] + + # ---- request and response bodies (SPEC §14.3.4) ---- + Health: + type: object + required: [status, server_version, schema_version, db_ok] + properties: + status: + type: string + enum: [ok, degraded] + server_version: + type: string + schema_version: + type: integer + db_ok: + type: boolean + SetupStatus: + type: object + required: [initialized] + properties: + initialized: + type: boolean + AcceptInviteRequest: + type: object + required: [code, device_name] + properties: + code: + type: string + description: Normalised per SPEC §8.3 by the app before sending. + device_name: + type: string + display_name: + type: string + description: Required for `admin_setup` and `partner` invites (`invalid_request` if missing), ignored for `device`. + AcceptInviteResponse: + type: object + required: [token, user, device, household] + properties: + token: + type: string + description: '`kvt_` + base64url without padding of 32 random bytes (47 characters, SPEC §8.2).' + user: + $ref: '#/components/schemas/User' + device: + $ref: '#/components/schemas/Device' + household: + type: array + items: + $ref: '#/components/schemas/User' + ApkLatest: + type: object + description: '`latest.json` (SPEC §10.6).' + required: [version_code, version_name, file_name, size_bytes, sha256, released_at] + properties: + version_code: + type: integer + version_name: + type: string + file_name: + type: string + size_bytes: + type: integer + format: int64 + sha256: + type: string + released_at: + $ref: '#/components/schemas/Timestamp' + MeResponse: + type: object + required: [user, device, household, public_url] + properties: + user: + $ref: '#/components/schemas/User' + device: + $ref: '#/components/schemas/Device' + household: + type: array + items: + $ref: '#/components/schemas/User' + public_url: + type: string + PatchMeRequest: + type: object + minProperties: 1 + properties: + display_name: + type: string + CreateInviteRequest: + type: object + required: [kind] + properties: + kind: + type: string + enum: [partner, device] + target_user_id: + # TODO(spec): §14.3.4 E-12 writes `uuid?` in a request; read here as nullable and optional (absent = null). + description: Required for `device`, must be null for `partner`. + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + InviteList: + type: object + required: [invites] + properties: + invites: + type: array + items: + $ref: '#/components/schemas/Invite' + DeviceList: + type: object + required: [devices] + properties: + devices: + type: array + items: + $ref: '#/components/schemas/Device' + PushTokenRequest: + type: object + # TODO(spec): §14.3.4 E-17 writes `string?`; read here as required and nullable (null clears), so an empty body is invalid_request. + required: [fcm_token] + properties: + fcm_token: + type: [string, 'null'] + CreateAccountRequest: + type: object + required: [name, default_shares] + properties: + name: + type: string + default_shares: + type: array + minItems: 2 + maxItems: 2 + items: + $ref: '#/components/schemas/Share' + PatchAccountRequest: + type: object + minProperties: 1 + properties: + name: + type: string + default_shares: + type: array + minItems: 2 + maxItems: 2 + items: + $ref: '#/components/schemas/Share' + CreateCategoryRequest: + type: object + required: [name, icon, color] + properties: + name: + type: string + icon: + type: string + color: + type: string + PatchCategoryRequest: + type: object + minProperties: 1 + properties: + name: + type: [string, 'null'] + description: null resets a default category's name (`category_is_default` on a non-default category). + icon: + type: string + color: + type: string + sort_order: + type: integer + CreateRecurringTemplateRequest: + type: object + description: | + `splits` per SPEC §9.1: none for `default`; two bp entries for `ratio` and `full`; two öre entries summing to the + amount for `exact`. + required: [account_id, category_id, amount_ore, description, notes, payer_user_id, split_mode, splits, day_of_month, + start_period, end_period, paused] + properties: + account_id: + $ref: '#/components/schemas/Uuid' + category_id: + $ref: '#/components/schemas/Uuid' + amount_ore: + $ref: '#/components/schemas/Ore' + description: + type: string + notes: + type: string + payer_user_id: + $ref: '#/components/schemas/Uuid' + split_mode: + $ref: '#/components/schemas/SplitMode' + splits: + type: array + items: + $ref: '#/components/schemas/TemplateSplit' + day_of_month: + type: integer + start_period: + $ref: '#/components/schemas/Period' + end_period: + oneOf: + - $ref: '#/components/schemas/Period' + - type: 'null' + paused: + type: boolean + PatchRecurringTemplateRequest: + type: object + description: Any subset of the E-28 fields; `split_mode` and `splits` must be sent together. + minProperties: 1 + dependentRequired: + split_mode: [splits] + splits: [split_mode] + properties: + account_id: + $ref: '#/components/schemas/Uuid' + category_id: + $ref: '#/components/schemas/Uuid' + amount_ore: + $ref: '#/components/schemas/Ore' + description: + type: string + notes: + type: string + payer_user_id: + $ref: '#/components/schemas/Uuid' + split_mode: + $ref: '#/components/schemas/SplitMode' + splits: + type: array + items: + $ref: '#/components/schemas/TemplateSplit' + day_of_month: + type: integer + start_period: + $ref: '#/components/schemas/Period' + end_period: + oneOf: + - $ref: '#/components/schemas/Period' + - type: 'null' + paused: + type: boolean + ProposalRequest: + type: object + required: [payer_user_id, amount_ore] + properties: + payer_user_id: + $ref: '#/components/schemas/Uuid' + amount_ore: + $ref: '#/components/schemas/Ore' + exclude_settlement_id: + # TODO(spec): §14.3.4 E-33 writes `uuid?` in a request; read here as nullable and optional (absent = null). + description: The settlement being edited, excluded from the balances. + oneOf: + - $ref: '#/components/schemas/Uuid' + - type: 'null' + ProposalResponse: + type: object + required: [allocations, excess_ore, over_settled_account_ids] + properties: + allocations: + type: array + description: In the order produced by SPEC §9.4. + items: + $ref: '#/components/schemas/Allocation' + excess_ore: + $ref: '#/components/schemas/Ore' + over_settled_account_ids: + type: array + items: + $ref: '#/components/schemas/Uuid' + + # ---- sync (SPEC §14.4) ---- + SyncPullResponse: + type: object + required: [epoch, since, next_since, has_more, server_time, users, categories, accounts, recurring_templates, expenses, + settlements, audit_entries] + properties: + epoch: + type: string + description: '`meta.sync_epoch`, 32 lowercase hex characters.' + pattern: '^[0-9a-f]{32}$' + since: + type: integer + format: int64 + next_since: + type: integer + format: int64 + description: The highest `server_version` in the page, or `since` when the page is empty. + has_more: + type: boolean + server_time: + $ref: '#/components/schemas/Timestamp' + users: + type: array + items: + $ref: '#/components/schemas/User' + categories: + type: array + items: + $ref: '#/components/schemas/Category' + accounts: + type: array + items: + $ref: '#/components/schemas/Account' + recurring_templates: + type: array + items: + $ref: '#/components/schemas/RecurringTemplate' + expenses: + type: array + items: + $ref: '#/components/schemas/Expense' + settlements: + type: array + items: + $ref: '#/components/schemas/Settlement' + audit_entries: + type: array + items: + $ref: '#/components/schemas/AuditEntry' + PushedExpense: + type: object + description: | + Exactly the client-writable fields (SPEC §14.4.3). Server-owned fields are ignored if sent. A delete is the full + entity with `deleted_at` set. + required: [id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, split_mode, splits, + created_at, updated_at, deleted_at] + properties: + id: + $ref: '#/components/schemas/Uuid' + account_id: + $ref: '#/components/schemas/Uuid' + category_id: + $ref: '#/components/schemas/Uuid' + amount_ore: + $ref: '#/components/schemas/Ore' + description: + type: string + notes: + type: string + expense_date: + $ref: '#/components/schemas/Date' + payer_user_id: + $ref: '#/components/schemas/Uuid' + split_mode: + $ref: '#/components/schemas/SplitMode' + splits: + type: array + items: + $ref: '#/components/schemas/Split' + created_at: + $ref: '#/components/schemas/Timestamp' + updated_at: + $ref: '#/components/schemas/Timestamp' + deleted_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + PushedSettlement: + type: object + description: Exactly the client-writable fields (SPEC §14.4.3). + required: [id, from_user_id, to_user_id, amount_ore, settlement_date, note, allocations, created_at, updated_at, deleted_at] + properties: + id: + $ref: '#/components/schemas/Uuid' + from_user_id: + $ref: '#/components/schemas/Uuid' + to_user_id: + $ref: '#/components/schemas/Uuid' + amount_ore: + $ref: '#/components/schemas/Ore' + settlement_date: + $ref: '#/components/schemas/Date' + note: + type: string + allocations: + type: array + items: + $ref: '#/components/schemas/Allocation' + created_at: + $ref: '#/components/schemas/Timestamp' + updated_at: + $ref: '#/components/schemas/Timestamp' + deleted_at: + oneOf: + - $ref: '#/components/schemas/Timestamp' + - type: 'null' + ExpenseMutation: + type: object + required: [mutation_id, entity_type, entity] + properties: + mutation_id: + $ref: '#/components/schemas/Uuid' + entity_type: + const: expense + entity: + $ref: '#/components/schemas/PushedExpense' + SettlementMutation: + type: object + required: [mutation_id, entity_type, entity] + properties: + mutation_id: + $ref: '#/components/schemas/Uuid' + entity_type: + const: settlement + entity: + $ref: '#/components/schemas/PushedSettlement' + Mutation: + oneOf: + - $ref: '#/components/schemas/ExpenseMutation' + - $ref: '#/components/schemas/SettlementMutation' + discriminator: + propertyName: entity_type + mapping: + expense: '#/components/schemas/ExpenseMutation' + settlement: '#/components/schemas/SettlementMutation' + SyncPushRequest: + type: object + required: [mutations] + properties: + mutations: + type: array + description: 1 to 100 mutations (more is 422 `batch_too_large`). + items: + $ref: '#/components/schemas/Mutation' + AcceptedResult: + type: object + # TODO(spec): §14.3.1 says optional response fields are present with null, but the §14.4.3 example gives accepted and + # rejected results disjoint key sets; the example's key sets are used here. + required: [mutation_id, entity_type, entity_id, status, server_version, entity] + properties: + mutation_id: + $ref: '#/components/schemas/Uuid' + entity_type: + $ref: '#/components/schemas/EntityType' + entity_id: + $ref: '#/components/schemas/Uuid' + status: + const: accepted + server_version: + $ref: '#/components/schemas/ServerVersionNumber' + entity: + description: The full wire object after processing, of the type named by `entity_type`. + oneOf: + - $ref: '#/components/schemas/Expense' + - $ref: '#/components/schemas/Settlement' + RejectedResult: + type: object + required: [mutation_id, entity_type, entity_id, status, reason, message, current] + properties: + mutation_id: + $ref: '#/components/schemas/Uuid' + entity_type: + $ref: '#/components/schemas/EntityType' + entity_id: + $ref: '#/components/schemas/Uuid' + status: + const: rejected + reason: + $ref: '#/components/schemas/RejectionReason' + message: + type: string + description: For validation failures, the domain error token of SPEC §14.5.3. + current: + description: The stored row as a full wire object of the type named by `entity_type`, or null when the server has no row with that id. + oneOf: + - $ref: '#/components/schemas/Expense' + - $ref: '#/components/schemas/Settlement' + - type: 'null' + MutationResult: + oneOf: + - $ref: '#/components/schemas/AcceptedResult' + - $ref: '#/components/schemas/RejectedResult' + discriminator: + propertyName: status + mapping: + accepted: '#/components/schemas/AcceptedResult' + rejected: '#/components/schemas/RejectedResult' + SyncPushResponse: + type: object + required: [server_time, results] + properties: + server_time: + $ref: '#/components/schemas/Timestamp' + results: + type: array + description: One result per mutation, in request order. + items: + $ref: '#/components/schemas/MutationResult' diff --git a/contracts/schema/server-v1.sql b/contracts/schema/server-v1.sql new file mode 100644 index 0000000..7273ff5 --- /dev/null +++ b/contracts/schema/server-v1.sql @@ -0,0 +1,230 @@ +-- Kvitt server schema v1. Do not edit; add a new migration and bump the schema version (SPEC §14.8). + +CREATE TABLE meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL +) STRICT; + +-- One global, strictly increasing counter for server_version. Exactly one row. +CREATE TABLE sync_sequence ( + id INTEGER PRIMARY KEY CHECK (id = 1), + value INTEGER NOT NULL +) STRICT; + +CREATE TABLE users ( + id TEXT PRIMARY KEY NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin', 'member')), + display_name TEXT NOT NULL CHECK (length(display_name) BETWEEN 1 AND 40), + picture_version INTEGER CHECK (picture_version IS NULL OR picture_version >= 1), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + server_version INTEGER NOT NULL UNIQUE +) STRICT; +CREATE UNIQUE INDEX users_single_admin ON users (role) WHERE role = 'admin'; + +CREATE TABLE devices ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users (id), + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60), + token_hash TEXT NOT NULL UNIQUE, + fcm_token TEXT, + app_version_code INTEGER, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + revoked_at TEXT, + revoked_by_user_id TEXT REFERENCES users (id) +) STRICT; +CREATE INDEX devices_user ON devices (user_id); + +CREATE TABLE invites ( + id TEXT PRIMARY KEY NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('admin_setup', 'partner', 'device')), + code_hash TEXT NOT NULL UNIQUE, + target_user_id TEXT REFERENCES users (id), + created_by_user_id TEXT REFERENCES users (id), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + accepted_at TEXT, + accepted_device_id TEXT REFERENCES devices (id), + cancelled_at TEXT, + CHECK ((kind = 'device') = (target_user_id IS NOT NULL)), + CHECK ((kind = 'admin_setup') = (created_by_user_id IS NULL) OR kind = 'device') +) STRICT; + +CREATE TABLE accounts ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 60), + name_key TEXT NOT NULL, + created_by_user_id TEXT NOT NULL REFERENCES users (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT, + server_version INTEGER NOT NULL UNIQUE +) STRICT; +-- name_key = Unicode-lowercased, trimmed name, computed in Rust. +CREATE UNIQUE INDEX accounts_name_live ON accounts (name_key) WHERE deleted_at IS NULL; + +CREATE TABLE account_default_shares ( + account_id TEXT NOT NULL REFERENCES accounts (id), + user_id TEXT NOT NULL REFERENCES users (id), + share_bp INTEGER NOT NULL CHECK (share_bp BETWEEN 0 AND 10000), + PRIMARY KEY (account_id, user_id) +) STRICT, WITHOUT ROWID; + +CREATE TABLE categories ( + id TEXT PRIMARY KEY NOT NULL, + default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN + ('groceries', 'restaurants', 'home', 'utilities', 'transport', + 'travel', 'entertainment', 'health', 'shopping', 'other')), + name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40), + icon TEXT NOT NULL, + color TEXT NOT NULL, + sort_order INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT, + server_version INTEGER NOT NULL UNIQUE, + CHECK (name IS NOT NULL OR default_key IS NOT NULL) +) STRICT; + +CREATE TABLE recurring_templates ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts (id), + category_id TEXT NOT NULL REFERENCES categories (id), + amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000), + description TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100), + notes TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000), + payer_user_id TEXT NOT NULL REFERENCES users (id), + split_mode TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')), + day_of_month INTEGER NOT NULL CHECK (day_of_month BETWEEN 1 AND 31), + start_period TEXT NOT NULL, + end_period TEXT, + paused INTEGER NOT NULL DEFAULT 0 CHECK (paused IN (0, 1)), + last_generated_period TEXT, + created_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_user_id TEXT NOT NULL REFERENCES users (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + server_version INTEGER NOT NULL UNIQUE +) STRICT; +CREATE INDEX recurring_templates_account ON recurring_templates (account_id); + +-- Modes ratio and full: share_bp set, share_ore NULL. Mode exact: share_ore set, share_bp NULL. Mode default: no rows. +CREATE TABLE recurring_template_splits ( + template_id TEXT NOT NULL REFERENCES recurring_templates (id), + user_id TEXT NOT NULL REFERENCES users (id), + share_bp INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000), + share_ore INTEGER CHECK (share_ore IS NULL OR share_ore >= 0), + PRIMARY KEY (template_id, user_id), + CHECK ((share_bp IS NULL) <> (share_ore IS NULL)) +) STRICT, WITHOUT ROWID; + +CREATE TABLE expenses ( + id TEXT PRIMARY KEY NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts (id), + category_id TEXT NOT NULL REFERENCES categories (id), + amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000), + description TEXT NOT NULL CHECK (length(description) BETWEEN 1 AND 100), + notes TEXT NOT NULL DEFAULT '' CHECK (length(notes) <= 1000), + expense_date TEXT NOT NULL, + payer_user_id TEXT NOT NULL REFERENCES users (id), + split_mode TEXT NOT NULL CHECK (split_mode IN ('default', 'ratio', 'exact', 'full')), + recurring_template_id TEXT REFERENCES recurring_templates (id), + recurring_period TEXT, + import_key TEXT UNIQUE, + created_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_device_id TEXT REFERENCES devices (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + server_received_at TEXT NOT NULL, + server_version INTEGER NOT NULL UNIQUE, + CHECK ((recurring_template_id IS NULL) = (recurring_period IS NULL)) +) STRICT; +CREATE UNIQUE INDEX expenses_recurring_once ON expenses (recurring_template_id, recurring_period) + WHERE recurring_template_id IS NOT NULL; +CREATE INDEX expenses_account ON expenses (account_id); +CREATE INDEX expenses_category ON expenses (category_id); + +-- Exactly two rows per expense, one per household user. share_bp NULL for mode exact. +CREATE TABLE expense_splits ( + expense_id TEXT NOT NULL REFERENCES expenses (id), + user_id TEXT NOT NULL REFERENCES users (id), + share_ore INTEGER NOT NULL CHECK (share_ore >= 0), + share_bp INTEGER CHECK (share_bp IS NULL OR share_bp BETWEEN 0 AND 10000), + PRIMARY KEY (expense_id, user_id) +) STRICT, WITHOUT ROWID; + +CREATE TABLE settlements ( + id TEXT PRIMARY KEY NOT NULL, + from_user_id TEXT NOT NULL REFERENCES users (id), + to_user_id TEXT NOT NULL REFERENCES users (id), + amount_ore INTEGER NOT NULL CHECK (amount_ore BETWEEN 1 AND 1000000000), + settlement_date TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 200), + import_key TEXT UNIQUE, + created_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_user_id TEXT NOT NULL REFERENCES users (id), + updated_by_device_id TEXT REFERENCES devices (id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + server_received_at TEXT NOT NULL, + server_version INTEGER NOT NULL UNIQUE, + CHECK (from_user_id <> to_user_id) +) STRICT; + +CREATE TABLE settlement_allocations ( + settlement_id TEXT NOT NULL REFERENCES settlements (id), + account_id TEXT NOT NULL REFERENCES accounts (id), + amount_ore INTEGER NOT NULL CHECK (amount_ore >= 1), + PRIMARY KEY (settlement_id, account_id) +) STRICT, WITHOUT ROWID; +CREATE INDEX settlement_allocations_account ON settlement_allocations (account_id); + +-- Append-only. changes is a JSON array of {"field","old","new"} (SPEC §14.4.7). +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY NOT NULL, + entity_type TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')), + entity_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')), + source TEXT NOT NULL CHECK (source IN ('app', 'recurring', 'import')), + actor_user_id TEXT NOT NULL REFERENCES users (id), + actor_device_id TEXT REFERENCES devices (id), + client_at TEXT NOT NULL, + server_at TEXT NOT NULL, + changes TEXT NOT NULL, + server_version INTEGER NOT NULL UNIQUE +) STRICT; +CREATE INDEX audit_log_entity ON audit_log (entity_type, entity_id); + +-- Idempotency of POST /sync. Rows older than 90 days are pruned daily. +CREATE TABLE sync_mutations ( + mutation_id TEXT PRIMARY KEY NOT NULL, + device_id TEXT NOT NULL REFERENCES devices (id), + entity_type TEXT NOT NULL CHECK (entity_type IN ('expense', 'settlement')), + entity_id TEXT NOT NULL, + result TEXT NOT NULL, + received_at TEXT NOT NULL +) STRICT; +CREATE INDEX sync_mutations_received ON sync_mutations (received_at); + +INSERT INTO sync_sequence (id, value) VALUES (1, 10); +INSERT INTO meta (key, value) VALUES ('schema_version', '1'); +INSERT INTO meta (key, value) VALUES ('sync_epoch', lower(hex(randomblob(16)))); + +INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version) VALUES + ('01920000-0000-7000-8000-000000000001', 'groceries', NULL, 'shopping_cart', 'green', 10, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 1), + ('01920000-0000-7000-8000-000000000002', 'restaurants', NULL, 'restaurant', 'orange', 20, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 2), + ('01920000-0000-7000-8000-000000000003', 'home', NULL, 'home', 'brown', 30, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 3), + ('01920000-0000-7000-8000-000000000004', 'utilities', NULL, 'bolt', 'yellow', 40, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 4), + ('01920000-0000-7000-8000-000000000005', 'transport', NULL, 'directions_car', 'blue', 50, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 5), + ('01920000-0000-7000-8000-000000000006', 'travel', NULL, 'flight', 'teal', 60, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 6), + ('01920000-0000-7000-8000-000000000007', 'entertainment', NULL, 'movie', 'purple', 70, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 7), + ('01920000-0000-7000-8000-000000000008', 'health', NULL, 'medical_services', 'red', 80, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 8), + ('01920000-0000-7000-8000-000000000009', 'shopping', NULL, 'shopping_bag', 'pink', 90, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 9), + ('01920000-0000-7000-8000-00000000000a', 'other', NULL, 'category', 'grey', 100, '2026-10-17T00:00:00.000Z', '2026-10-17T00:00:00.000Z', NULL, NULL, 10); diff --git a/contracts/test-vectors/compute_balances.json b/contracts/test-vectors/compute_balances.json new file mode 100644 index 0000000..2acd806 --- /dev/null +++ b/contracts/test-vectors/compute_balances.json @@ -0,0 +1,141 @@ +{ + "format": 1, + "function": "compute_balances", + "cases": [ + { + "name": "empty: no accounts, no rows", + "input": { "user_ids": ["a", "b"], "account_ids": [], "expenses": [], "settlements": [] }, + "expected": { "ok": { "per_account": {}, "global": { "a": 0, "b": 0 } } } + }, + { + "name": "one account, (1001, payer a, 50/50) and (100, payer b, a 6000 bp): a +440, b -440", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x"], + "expenses": [ + { "account_id": "x", "amount_ore": 1001, "payer_user_id": "a", "shares": { "a": 501, "b": 500 } }, + { "account_id": "x", "amount_ore": 100, "payer_user_id": "b", "shares": { "a": 60, "b": 40 } } + ], + "settlements": [] + }, + "expected": { "ok": { "per_account": { "x": { "a": 440, "b": -440 } }, "global": { "a": 440, "b": -440 } } } + }, + { + "name": "same account plus settlement b -> a 440: all zero", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x"], + "expenses": [ + { "account_id": "x", "amount_ore": 1001, "payer_user_id": "a", "shares": { "a": 501, "b": 500 } }, + { "account_id": "x", "amount_ore": 100, "payer_user_id": "b", "shares": { "a": 60, "b": 40 } } + ], + "settlements": [ { "from_user_id": "b", "to_user_id": "a", "allocations": { "x": 440 } } ] + }, + "expected": { "ok": { "per_account": { "x": { "a": 0, "b": 0 } }, "global": { "a": 0, "b": 0 } } } + }, + { + "name": "two accounts with opposite signs: global is the sum", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x", "y"], + "expenses": [ + { "account_id": "x", "amount_ore": 2000, "payer_user_id": "a", "shares": { "a": 1000, "b": 1000 } }, + { "account_id": "y", "amount_ore": 600, "payer_user_id": "b", "shares": { "a": 300, "b": 300 } } + ], + "settlements": [] + }, + "expected": { "ok": { "per_account": { "x": { "a": 1000, "b": -1000 }, "y": { "a": -300, "b": 300 } }, "global": { "a": 700, "b": -700 } } } + }, + { + "name": "an account in account_ids with no rows has zeros", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x", "y"], + "expenses": [ { "account_id": "x", "amount_ore": 1001, "payer_user_id": "a", "shares": { "a": 501, "b": 500 } } ], + "settlements": [] + }, + "expected": { "ok": { "per_account": { "x": { "a": 500, "b": -500 }, "y": { "a": 0, "b": 0 } }, "global": { "a": 500, "b": -500 } } } + }, + { + "name": "archived accounts are included: the archived account is in account_ids and counts toward global", + "input": { + "user_ids": ["a", "b"], "account_ids": ["live", "arch"], + "expenses": [ + { "account_id": "live", "amount_ore": 1000, "payer_user_id": "a", "shares": { "a": 500, "b": 500 } }, + { "account_id": "arch", "amount_ore": 5000, "payer_user_id": "b", "shares": { "a": 2500, "b": 2500 } } + ], + "settlements": [] + }, + "expected": { "ok": { "per_account": { "live": { "a": 500, "b": -500 }, "arch": { "a": -2500, "b": 2500 } }, "global": { "a": -2000, "b": 2000 } } } + }, + { + "name": "account ids referenced by rows but not in account_ids still count", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x"], + "expenses": [ { "account_id": "z", "amount_ore": 100, "payer_user_id": "b", "shares": { "a": 100, "b": 0 } } ], + "settlements": [ { "from_user_id": "a", "to_user_id": "b", "allocations": { "w": 30 } } ] + }, + "expected": { "ok": { "per_account": { "x": { "a": 0, "b": 0 }, "z": { "a": -100, "b": 100 }, "w": { "a": 30, "b": -30 } }, "global": { "a": -70, "b": 70 } } } + }, + { + "name": "settlement with allocations to two accounts", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x", "y"], + "expenses": [ + { "account_id": "x", "amount_ore": 10000, "payer_user_id": "a", "shares": { "a": 5000, "b": 5000 } }, + { "account_id": "y", "amount_ore": 3000, "payer_user_id": "a", "shares": { "a": 0, "b": 3000 } } + ], + "settlements": [ { "from_user_id": "b", "to_user_id": "a", "allocations": { "x": 5000, "y": 1000 } } ] + }, + "expected": { "ok": { "per_account": { "x": { "a": 0, "b": 0 }, "y": { "a": 2000, "b": -2000 } }, "global": { "a": 2000, "b": -2000 } } } + }, + { + "name": "over-settling flips the account's sign", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x"], + "expenses": [ { "account_id": "x", "amount_ore": 1000, "payer_user_id": "a", "shares": { "a": 500, "b": 500 } } ], + "settlements": [ { "from_user_id": "b", "to_user_id": "a", "allocations": { "x": 800 } } ] + }, + "expected": { "ok": { "per_account": { "x": { "a": -300, "b": 300 } }, "global": { "a": -300, "b": 300 } } } + }, + { + "name": "partial settlement in the other direction", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x"], + "expenses": [ { "account_id": "x", "amount_ore": 1000, "payer_user_id": "b", "shares": { "a": 500, "b": 500 } } ], + "settlements": [ { "from_user_id": "a", "to_user_id": "b", "allocations": { "x": 200 } } ] + }, + "expected": { "ok": { "per_account": { "x": { "a": -300, "b": 300 } }, "global": { "a": -300, "b": 300 } } } + }, + { + "name": "expense fully borne by the payer leaves the balance unchanged", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x"], + "expenses": [ + { "account_id": "x", "amount_ore": 1001, "payer_user_id": "a", "shares": { "a": 501, "b": 500 } }, + { "account_id": "x", "amount_ore": 7000, "payer_user_id": "a", "shares": { "a": 7000, "b": 0 } } + ], + "settlements": [] + }, + "expected": { "ok": { "per_account": { "x": { "a": 500, "b": -500 } }, "global": { "a": 500, "b": -500 } } } + }, + { + "name": "tombstoned rows excluded: callers pass only non-deleted rows (§14.5.2); the account's deleted expense and deleted settlement are absent from the input", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x"], + "expenses": [ { "account_id": "x", "amount_ore": 2000, "payer_user_id": "b", "shares": { "a": 1000, "b": 1000 } } ], + "settlements": [] + }, + "expected": { "ok": { "per_account": { "x": { "a": -1000, "b": 1000 } }, "global": { "a": -1000, "b": 1000 } } } + }, + { + "name": "maximum amounts stay exact in 64-bit integers", + "input": { + "user_ids": ["a", "b"], "account_ids": ["x"], + "expenses": [ + { "account_id": "x", "amount_ore": 1000000000, "payer_user_id": "a", "shares": { "a": 0, "b": 1000000000 } }, + { "account_id": "x", "amount_ore": 1000000000, "payer_user_id": "a", "shares": { "a": 0, "b": 1000000000 } }, + { "account_id": "x", "amount_ore": 1000000000, "payer_user_id": "a", "shares": { "a": 0, "b": 1000000000 } } + ], + "settlements": [] + }, + "expected": { "ok": { "per_account": { "x": { "a": 3000000000, "b": -3000000000 } }, "global": { "a": 3000000000, "b": -3000000000 } } } + } + ] +} diff --git a/contracts/test-vectors/compute_split.json b/contracts/test-vectors/compute_split.json new file mode 100644 index 0000000..b9ca41e --- /dev/null +++ b/contracts/test-vectors/compute_split.json @@ -0,0 +1,216 @@ +{ + "format": 1, + "function": "compute_split", + "cases": [ + { + "name": "§9.1 row 1: 1001, payer a, 5000/5000: payer gets the extra öre", + "input": { "amount_ore": 1001, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } } }, + "expected": { "ok": { "a": 501, "b": 500 } } + }, + { + "name": "§9.1 row 2: 1, payer a, 5000/5000: the single öre goes to the payer", + "input": { "amount_ore": 1, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } } }, + "expected": { "ok": { "a": 1, "b": 0 } } + }, + { + "name": "§9.1 row 3: 100, payer b, 4000/6000", + "input": { "amount_ore": 100, "payer_user_id": "b", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 6000, "b": 4000 } } }, + "expected": { "ok": { "a": 60, "b": 40 } } + }, + { + "name": "§9.1 row 4: 999, payer a, 3333/6667", + "input": { "amount_ore": 999, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 3333, "b": 6667 } } }, + "expected": { "ok": { "a": 333, "b": 666 } } + }, + { + "name": "§9.1 row 5: 999, payer b, 6667/3333", + "input": { "amount_ore": 999, "payer_user_id": "b", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 3333, "b": 6667 } } }, + "expected": { "ok": { "a": 332, "b": 667 } } + }, + { + "name": "§9.1 row 6: 50000, payer a, 0/10000", + "input": { "amount_ore": 50000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 0, "b": 10000 } } }, + "expected": { "ok": { "a": 0, "b": 50000 } } + }, + { + "name": "§9.1 row 7: 50000, payer a, 10000/0", + "input": { "amount_ore": 50000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 10000, "b": 0 } } }, + "expected": { "ok": { "a": 50000, "b": 0 } } + }, + { + "name": "user_ids order does not matter: payer b listed second, remainder still to payer", + "input": { "amount_ore": 1001, "payer_user_id": "b", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } } }, + "expected": { "ok": { "a": 500, "b": 501 } } + }, + { + "name": "user_ids given in reverse order", + "input": { "amount_ore": 999, "payer_user_id": "a", "user_ids": ["b", "a"], "spec": { "mode": "ratio", "bp": { "a": 3333, "b": 6667 } } }, + "expected": { "ok": { "a": 333, "b": 666 } } + }, + { + "name": "non-payer exact share 0.9 öre: rounded down to 0, payer bears the whole amount", + "input": { "amount_ore": 9, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 9000, "b": 1000 } } }, + "expected": { "ok": { "a": 9, "b": 0 } } + }, + { + "name": "full on the payer", + "input": { "amount_ore": 1001, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "full", "bearer_user_id": "a" } }, + "expected": { "ok": { "a": 1001, "b": 0 } } + }, + { + "name": "full on the non-payer", + "input": { "amount_ore": 1001, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "full", "bearer_user_id": "b" } }, + "expected": { "ok": { "a": 0, "b": 1001 } } + }, + { + "name": "exact valid: shares sum exactly to the amount", + "input": { "amount_ore": 1001, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "exact", "ore": { "a": 300, "b": 701 } } }, + "expected": { "ok": { "a": 300, "b": 701 } } + }, + { + "name": "exact valid with a zero share", + "input": { "amount_ore": 41200, "payer_user_id": "b", "user_ids": ["a", "b"], "spec": { "mode": "exact", "ore": { "a": 41200, "b": 0 } } }, + "expected": { "ok": { "a": 41200, "b": 0 } } + }, + { + "name": "exact sum mismatch: one öre short", + "input": { "amount_ore": 1001, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "exact", "ore": { "a": 300, "b": 700 } } }, + "expected": { "error": "exact_sum_mismatch" } + }, + { + "name": "exact sum mismatch: one öre over", + "input": { "amount_ore": 1001, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "exact", "ore": { "a": 300, "b": 702 } } }, + "expected": { "error": "exact_sum_mismatch" } + }, + { + "name": "exact negative share (sum would match)", + "input": { "amount_ore": 1001, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "exact", "ore": { "a": -100, "b": 1101 } } }, + "expected": { "error": "exact_negative" } + }, + { + "name": "amount 0", + "input": { "amount_ore": 0, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } } }, + "expected": { "error": "amount_out_of_range" } + }, + { + "name": "amount negative", + "input": { "amount_ore": -1, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } } }, + "expected": { "error": "amount_out_of_range" } + }, + { + "name": "amount 1 000 000 001", + "input": { "amount_ore": 1000000001, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } } }, + "expected": { "error": "amount_out_of_range" } + }, + { + "name": "amount 1 000 000 000 with 3333 bp: 64-bit intermediate, no overflow", + "input": { "amount_ore": 1000000000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 6667, "b": 3333 } } }, + "expected": { "ok": { "a": 666700000, "b": 333300000 } } + }, + { + "name": "amount 999 999 999 with 3333 bp on the non-payer: remainder to payer at the top of the range", + "input": { "amount_ore": 999999999, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 6667, "b": 3333 } } }, + "expected": { "ok": { "a": 666700000, "b": 333299999 } } + }, + { + "name": "amount 1 000 000 000 exactly, full on the non-payer", + "input": { "amount_ore": 1000000000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "full", "bearer_user_id": "b" } }, + "expected": { "ok": { "a": 0, "b": 1000000000 } } + }, + { + "name": "bp 10001", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 10001, "b": 0 } } }, + "expected": { "error": "bp_out_of_range" } + }, + { + "name": "ratio does not sum to 10000", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 4000 } } }, + "expected": { "error": "bp_sum_not_10000" } + }, + { + "name": "ratio sums to 10001", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5001, "b": 5000 } } }, + "expected": { "error": "bp_sum_not_10000" } + }, + { + "name": "three user ids", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b", "c"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } } }, + "expected": { "error": "users_invalid" } + }, + { + "name": "one user id", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a"], "spec": { "mode": "ratio", "bp": { "a": 10000 } } }, + "expected": { "error": "users_invalid" } + }, + { + "name": "two user ids that are not distinct", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "a"], "spec": { "mode": "ratio", "bp": { "a": 10000 } } }, + "expected": { "error": "users_invalid" } + }, + { + "name": "payer not in users", + "input": { "amount_ore": 1000, "payer_user_id": "x", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } } }, + "expected": { "error": "payer_unknown" } + }, + { + "name": "bearer not in users", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "full", "bearer_user_id": "x" } }, + "expected": { "error": "bearer_unknown" } + }, + { + "name": "ratio map keys do not match users", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "x": 5000 } } }, + "expected": { "error": "share_users_mismatch" } + }, + { + "name": "ratio map has an extra zero entry", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000, "x": 0 } } }, + "expected": { "error": "share_users_mismatch" } + }, + { + "name": "ratio map misses one user", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 10000 } } }, + "expected": { "error": "share_users_mismatch" } + }, + { + "name": "exact map keys do not match users", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "exact", "ore": { "a": 500, "x": 500 } } }, + "expected": { "error": "share_users_mismatch" } + }, + { + "name": "error order: amount checked before users", + "input": { "amount_ore": 0, "payer_user_id": "x", "user_ids": ["a", "b", "c"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 4000 } } }, + "expected": { "error": "amount_out_of_range" } + }, + { + "name": "error order: users checked before payer", + "input": { "amount_ore": 1000, "payer_user_id": "x", "user_ids": ["a", "b", "c"], "spec": { "mode": "ratio", "bp": { "a": 5000, "b": 5000 } } }, + "expected": { "error": "users_invalid" } + }, + { + "name": "error order: payer checked before bp range", + "input": { "amount_ore": 1000, "payer_user_id": "x", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 10001, "b": 0 } } }, + "expected": { "error": "payer_unknown" } + }, + { + "name": "error order: bp range checked before bp sum", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 10001, "b": 5000 } } }, + "expected": { "error": "bp_out_of_range" } + }, + { + "name": "error order: bp sum checked before share users", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "ratio", "bp": { "a": 5000, "x": 4000 } } }, + "expected": { "error": "bp_sum_not_10000" } + }, + { + "name": "error order: exact negative checked before exact sum", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "exact", "ore": { "a": -1, "b": 500 } } }, + "expected": { "error": "exact_negative" } + }, + { + "name": "error order: exact sum checked before share users", + "input": { "amount_ore": 1000, "payer_user_id": "a", "user_ids": ["a", "b"], "spec": { "mode": "exact", "ore": { "a": 500, "x": 400 } } }, + "expected": { "error": "exact_sum_mismatch" } + } + ] +} diff --git a/contracts/test-vectors/over_settled_accounts.json b/contracts/test-vectors/over_settled_accounts.json new file mode 100644 index 0000000..4ba3be4 --- /dev/null +++ b/contracts/test-vectors/over_settled_accounts.json @@ -0,0 +1,61 @@ +{ + "format": 1, + "function": "over_settled_accounts", + "cases": [ + { + "name": "none: every allocation within the debt (equal to the debt is not over)", + "input": { + "allocations": [ { "account_id": "X", "amount_ore": 30000 }, { "account_id": "Y", "amount_ore": 5000 } ], + "accounts": [ + { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 }, + { "account_id": "Y", "created_at": "2025-02-01T00:00:00.000Z", "archived": false, "payer_net_ore": -20000 } + ] + }, + "expected": { "ok": [] } + }, + { + "name": "one: Y gets one öre more than owed", + "input": { + "allocations": [ { "account_id": "X", "amount_ore": 30000 }, { "account_id": "Y", "amount_ore": 20001 } ], + "accounts": [ + { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 }, + { "account_id": "Y", "created_at": "2025-02-01T00:00:00.000Z", "archived": false, "payer_net_ore": -20000 } + ] + }, + "expected": { "ok": ["Y"] } + }, + { + "name": "an account where the payer is owed: any allocation is over (max(0, -net) = 0)", + "input": { + "allocations": [ { "account_id": "Z", "amount_ore": 1 } ], + "accounts": [ { "account_id": "Z", "created_at": "2024-12-01T00:00:00.000Z", "archived": false, "payer_net_ore": 10000 } ] + }, + "expected": { "ok": ["Z"] } + }, + { + "name": "an unknown account counts as net 0", + "input": { + "allocations": [ { "account_id": "X", "amount_ore": 100 }, { "account_id": "U", "amount_ore": 100 } ], + "accounts": [ { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 } ] + }, + "expected": { "ok": ["U"] } + }, + { + "name": "several: reported in allocation order, not account order", + "input": { + "allocations": [ { "account_id": "Y", "amount_ore": 25000 }, { "account_id": "X", "amount_ore": 100 }, { "account_id": "Z", "amount_ore": 1 } ], + "accounts": [ + { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 }, + { "account_id": "Y", "created_at": "2025-02-01T00:00:00.000Z", "archived": false, "payer_net_ore": -20000 }, + { "account_id": "Z", "created_at": "2024-12-01T00:00:00.000Z", "archived": true, "payer_net_ore": 0 } + ] + }, + "expected": { "ok": ["Y", "Z"] } + }, + { + "name": "no allocations", + "input": { "allocations": [], "accounts": [ { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 } ] }, + "expected": { "ok": [] } + } + ] +} diff --git a/contracts/test-vectors/propose_allocation.json b/contracts/test-vectors/propose_allocation.json new file mode 100644 index 0000000..3163535 --- /dev/null +++ b/contracts/test-vectors/propose_allocation.json @@ -0,0 +1,168 @@ +{ + "format": 1, + "function": "propose_allocation", + "cases": [ + { + "name": "owing X (-30000, oldest owing) and Y (-20000), owed in Z (+10000): 40000 gives X 30000, Y 10000", + "input": { + "amount_ore": 40000, + "accounts": [ + { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 }, + { "account_id": "Y", "created_at": "2025-02-01T00:00:00.000Z", "archived": false, "payer_net_ore": -20000 }, + { "account_id": "Z", "created_at": "2024-12-01T00:00:00.000Z", "archived": false, "payer_net_ore": 10000 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "X", "amount_ore": 30000 }, { "account_id": "Y", "amount_ore": 10000 } ], "excess_ore": 0, "over_settled_account_ids": [] } } + }, + { + "name": "same accounts, 50000 = exactly the total debt: no excess", + "input": { + "amount_ore": 50000, + "accounts": [ + { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 }, + { "account_id": "Y", "created_at": "2025-02-01T00:00:00.000Z", "archived": false, "payer_net_ore": -20000 }, + { "account_id": "Z", "created_at": "2024-12-01T00:00:00.000Z", "archived": false, "payer_net_ore": 10000 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "X", "amount_ore": 30000 }, { "account_id": "Y", "amount_ore": 20000 } ], "excess_ore": 0, "over_settled_account_ids": [] } } + }, + { + "name": "same accounts, 60000: excess 10000 added to the last owing account Y, over-settled [Y]", + "input": { + "amount_ore": 60000, + "accounts": [ + { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 }, + { "account_id": "Y", "created_at": "2025-02-01T00:00:00.000Z", "archived": false, "payer_net_ore": -20000 }, + { "account_id": "Z", "created_at": "2024-12-01T00:00:00.000Z", "archived": false, "payer_net_ore": 10000 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "X", "amount_ore": 30000 }, { "account_id": "Y", "amount_ore": 30000 } ], "excess_ore": 10000, "over_settled_account_ids": ["Y"] } } + }, + { + "name": "same accounts, 20000 is less than the first debt: only X", + "input": { + "amount_ore": 20000, + "accounts": [ + { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 }, + { "account_id": "Y", "created_at": "2025-02-01T00:00:00.000Z", "archived": false, "payer_net_ore": -20000 }, + { "account_id": "Z", "created_at": "2024-12-01T00:00:00.000Z", "archived": false, "payer_net_ore": 10000 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "X", "amount_ore": 20000 } ], "excess_ore": 0, "over_settled_account_ids": [] } } + }, + { + "name": "input order does not matter: accounts listed newest first", + "input": { + "amount_ore": 40000, + "accounts": [ + { "account_id": "Y", "created_at": "2025-02-01T00:00:00.000Z", "archived": false, "payer_net_ore": -20000 }, + { "account_id": "Z", "created_at": "2024-12-01T00:00:00.000Z", "archived": false, "payer_net_ore": 10000 }, + { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "X", "amount_ore": 30000 }, { "account_id": "Y", "amount_ore": 10000 } ], "excess_ore": 0, "over_settled_account_ids": [] } } + }, + { + "name": "no owing accounts: 5000 with only Z (owed) goes to Z, over-settled [Z]", + "input": { + "amount_ore": 5000, + "accounts": [ { "account_id": "Z", "created_at": "2024-12-01T00:00:00.000Z", "archived": false, "payer_net_ore": 10000 } ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "Z", "amount_ore": 5000 } ], "excess_ore": 5000, "over_settled_account_ids": ["Z"] } } + }, + { + "name": "no owing accounts: a net of exactly 0 is not owing; target is the oldest non-archived account", + "input": { + "amount_ore": 700, + "accounts": [ + { "account_id": "A", "created_at": "2024-01-01T00:00:00.000Z", "archived": true, "payer_net_ore": 0 }, + { "account_id": "B", "created_at": "2024-06-01T00:00:00.000Z", "archived": false, "payer_net_ore": 500 }, + { "account_id": "C", "created_at": "2024-03-01T00:00:00.000Z", "archived": false, "payer_net_ore": 0 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "C", "amount_ore": 700 } ], "excess_ore": 700, "over_settled_account_ids": ["C"] } } + }, + { + "name": "all accounts archived and none owing: the oldest account", + "input": { + "amount_ore": 1200, + "accounts": [ + { "account_id": "P", "created_at": "2025-05-01T00:00:00.000Z", "archived": true, "payer_net_ore": 0 }, + { "account_id": "Q", "created_at": "2025-03-01T00:00:00.000Z", "archived": true, "payer_net_ore": 300 }, + { "account_id": "R", "created_at": "2025-04-01T00:00:00.000Z", "archived": true, "payer_net_ore": 0 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "Q", "amount_ore": 1200 } ], "excess_ore": 1200, "over_settled_account_ids": ["Q"] } } + }, + { + "name": "archived owing accounts are still settled (archived never affects money)", + "input": { + "amount_ore": 1500, + "accounts": [ + { "account_id": "old", "created_at": "2024-01-01T00:00:00.000Z", "archived": true, "payer_net_ore": -1000 }, + { "account_id": "new", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -1000 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "old", "amount_ore": 1000 }, { "account_id": "new", "amount_ore": 500 } ], "excess_ore": 0, "over_settled_account_ids": [] } } + }, + { + "name": "equal created_at among owing accounts: ordered by id ascending", + "input": { + "amount_ore": 1500, + "accounts": [ + { "account_id": "q", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -1000 }, + { "account_id": "p", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -1000 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "p", "amount_ore": 1000 }, { "account_id": "q", "amount_ore": 500 } ], "excess_ore": 0, "over_settled_account_ids": [] } } + }, + { + "name": "equal created_at with no owing account: target chosen by id ascending", + "input": { + "amount_ore": 100, + "accounts": [ + { "account_id": "n", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": 0 }, + { "account_id": "m", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": 50 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "m", "amount_ore": 100 } ], "excess_ore": 100, "over_settled_account_ids": ["m"] } } + }, + { + "name": "single owing account, amount above its debt: excess to it", + "input": { + "amount_ore": 1000, + "accounts": [ + { "account_id": "Z", "created_at": "2024-12-01T00:00:00.000Z", "archived": false, "payer_net_ore": 10000 }, + { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -1 } + ] + }, + "expected": { "ok": { "allocations": [ { "account_id": "X", "amount_ore": 1000 } ], "excess_ore": 999, "over_settled_account_ids": ["X"] } } + }, + { + "name": "no accounts", + "input": { "amount_ore": 1000, "accounts": [] }, + "expected": { "error": "no_accounts" } + }, + { + "name": "amount 0", + "input": { + "amount_ore": 0, + "accounts": [ { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 } ] + }, + "expected": { "error": "amount_out_of_range" } + }, + { + "name": "amount 1 000 000 001", + "input": { + "amount_ore": 1000000001, + "accounts": [ { "account_id": "X", "created_at": "2025-01-01T00:00:00.000Z", "archived": false, "payer_net_ore": -30000 } ] + }, + "expected": { "error": "amount_out_of_range" } + }, + { + "name": "error order: amount checked before no accounts", + "input": { "amount_ore": 0, "accounts": [] }, + "expected": { "error": "amount_out_of_range" } + } + ] +} diff --git a/contracts/test-vectors/resolve_range.json b/contracts/test-vectors/resolve_range.json new file mode 100644 index 0000000..db1f46a --- /dev/null +++ b/contracts/test-vectors/resolve_range.json @@ -0,0 +1,214 @@ +{ + "format": 1, + "function": "resolve_range", + "cases": [ + { + "name": "this_month", + "input": { + "preset": "this_month", + "today": "2026-10-17", + "earliest_expense": "2024-03-05", + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2026-10-01", + "end_inclusive": "2026-10-17" + } + } + }, + { + "name": "last_3_months: the current month and the two before", + "input": { + "preset": "last_3_months", + "today": "2026-10-17", + "earliest_expense": "2024-03-05", + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2026-08-01", + "end_inclusive": "2026-10-17" + } + } + }, + { + "name": "last_6_months", + "input": { + "preset": "last_6_months", + "today": "2026-10-17", + "earliest_expense": "2024-03-05", + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2026-05-01", + "end_inclusive": "2026-10-17" + } + } + }, + { + "name": "last_12_months: crosses into the previous year", + "input": { + "preset": "last_12_months", + "today": "2026-10-17", + "earliest_expense": "2024-03-05", + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2025-11-01", + "end_inclusive": "2026-10-17" + } + } + }, + { + "name": "this_year", + "input": { + "preset": "this_year", + "today": "2026-10-17", + "earliest_expense": "2024-03-05", + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2026-01-01", + "end_inclusive": "2026-10-17" + } + } + }, + { + "name": "all_time with an earliest expense", + "input": { + "preset": "all_time", + "today": "2026-10-17", + "earliest_expense": "2024-03-05", + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2024-03-05", + "end_inclusive": "2026-10-17" + } + } + }, + { + "name": "all_time without expenses: today to today", + "input": { + "preset": "all_time", + "today": "2026-10-17", + "earliest_expense": null, + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2026-10-17", + "end_inclusive": "2026-10-17" + } + } + }, + { + "name": "custom in order, future dates allowed", + "input": { + "preset": "custom", + "today": "2026-10-17", + "earliest_expense": null, + "custom_start": "2026-11-01", + "custom_end": "2026-12-31" + }, + "expected": { + "ok": { + "start": "2026-11-01", + "end_inclusive": "2026-12-31" + } + } + }, + { + "name": "custom swapped when start is after end", + "input": { + "preset": "custom", + "today": "2026-10-17", + "earliest_expense": null, + "custom_start": "2026-12-31", + "custom_end": "2026-11-01" + }, + "expected": { + "ok": { + "start": "2026-11-01", + "end_inclusive": "2026-12-31" + } + } + }, + { + "name": "custom single day", + "input": { + "preset": "custom", + "today": "2026-10-17", + "earliest_expense": null, + "custom_start": "2026-02-28", + "custom_end": "2026-02-28" + }, + "expected": { + "ok": { + "start": "2026-02-28", + "end_inclusive": "2026-02-28" + } + } + }, + { + "name": "last_3_months in January crosses the year", + "input": { + "preset": "last_3_months", + "today": "2027-01-01", + "earliest_expense": null, + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2026-11-01", + "end_inclusive": "2027-01-01" + } + } + }, + { + "name": "last_12_months on 31 March: first of April the year before", + "input": { + "preset": "last_12_months", + "today": "2027-03-31", + "earliest_expense": null, + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2026-04-01", + "end_inclusive": "2027-03-31" + } + } + }, + { + "name": "this_month on the first day of the month", + "input": { + "preset": "this_month", + "today": "2026-11-01", + "earliest_expense": null, + "custom_start": null, + "custom_end": null + }, + "expected": { + "ok": { + "start": "2026-11-01", + "end_inclusive": "2026-11-01" + } + } + } + ] +} \ No newline at end of file diff --git a/contracts/test-vectors/spend_series.json b/contracts/test-vectors/spend_series.json new file mode 100644 index 0000000..d3cf4d4 --- /dev/null +++ b/contracts/test-vectors/spend_series.json @@ -0,0 +1,1173 @@ +{ + "format": 1, + "function": "spend_series", + "cases": [ + { + "name": "monthly across a year boundary, household, by category; edge buckets clipped, empty January kept, rows outside the range ignored", + "input": { + "expenses": [ + { + "date": "2026-11-14", + "account_id": "x", + "category_id": "c1", + "amount_ore": 1000, + "shares": { + "a": 500, + "b": 500 + } + }, + { + "date": "2026-11-15", + "account_id": "x", + "category_id": "c1", + "amount_ore": 2000, + "shares": { + "a": 1000, + "b": 1000 + } + }, + { + "date": "2026-11-30", + "account_id": "y", + "category_id": "c2", + "amount_ore": 3000, + "shares": { + "a": 3000, + "b": 0 + } + }, + { + "date": "2026-12-01", + "account_id": "x", + "category_id": "c2", + "amount_ore": 4000, + "shares": { + "a": 1000, + "b": 3000 + } + }, + { + "date": "2027-02-10", + "account_id": "y", + "category_id": "c1", + "amount_ore": 500, + "shares": { + "a": 250, + "b": 250 + } + }, + { + "date": "2027-02-11", + "account_id": "x", + "category_id": "c1", + "amount_ore": 700, + "shares": { + "a": 350, + "b": 350 + } + } + ], + "range": { + "start": "2026-11-15", + "end_inclusive": "2027-02-10" + }, + "granularity": "month", + "measure": "household", + "group_by": "category" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-11", + "start": "2026-11-15", + "end_inclusive": "2026-11-30", + "totals": { + "c1": 2000, + "c2": 3000 + }, + "total": 5000 + }, + { + "key": "2026-12", + "start": "2026-12-01", + "end_inclusive": "2026-12-31", + "totals": { + "c2": 4000 + }, + "total": 4000 + }, + { + "key": "2027-01", + "start": "2027-01-01", + "end_inclusive": "2027-01-31", + "totals": {}, + "total": 0 + }, + { + "key": "2027-02", + "start": "2027-02-01", + "end_inclusive": "2027-02-10", + "totals": { + "c1": 500 + }, + "total": 500 + } + ], + "group_totals": { + "c1": 2500, + "c2": 7000 + }, + "grand_total": 9500 + } + } + }, + { + "name": "monthly, share of a, by account", + "input": { + "expenses": [ + { + "date": "2026-11-14", + "account_id": "x", + "category_id": "c1", + "amount_ore": 1000, + "shares": { + "a": 500, + "b": 500 + } + }, + { + "date": "2026-11-15", + "account_id": "x", + "category_id": "c1", + "amount_ore": 2000, + "shares": { + "a": 1000, + "b": 1000 + } + }, + { + "date": "2026-11-30", + "account_id": "y", + "category_id": "c2", + "amount_ore": 3000, + "shares": { + "a": 3000, + "b": 0 + } + }, + { + "date": "2026-12-01", + "account_id": "x", + "category_id": "c2", + "amount_ore": 4000, + "shares": { + "a": 1000, + "b": 3000 + } + }, + { + "date": "2027-02-10", + "account_id": "y", + "category_id": "c1", + "amount_ore": 500, + "shares": { + "a": 250, + "b": 250 + } + }, + { + "date": "2027-02-11", + "account_id": "x", + "category_id": "c1", + "amount_ore": 700, + "shares": { + "a": 350, + "b": 350 + } + } + ], + "range": { + "start": "2026-11-15", + "end_inclusive": "2027-02-10" + }, + "granularity": "month", + "measure": { + "share": "a" + }, + "group_by": "account" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-11", + "start": "2026-11-15", + "end_inclusive": "2026-11-30", + "totals": { + "x": 1000, + "y": 3000 + }, + "total": 4000 + }, + { + "key": "2026-12", + "start": "2026-12-01", + "end_inclusive": "2026-12-31", + "totals": { + "x": 1000 + }, + "total": 1000 + }, + { + "key": "2027-01", + "start": "2027-01-01", + "end_inclusive": "2027-01-31", + "totals": {}, + "total": 0 + }, + { + "key": "2027-02", + "start": "2027-02-01", + "end_inclusive": "2027-02-10", + "totals": { + "y": 250 + }, + "total": 250 + } + ], + "group_totals": { + "x": 2000, + "y": 3250 + }, + "grand_total": 5250 + } + } + }, + { + "name": "monthly, share of b, by category: a zero share is omitted from totals", + "input": { + "expenses": [ + { + "date": "2026-11-14", + "account_id": "x", + "category_id": "c1", + "amount_ore": 1000, + "shares": { + "a": 500, + "b": 500 + } + }, + { + "date": "2026-11-15", + "account_id": "x", + "category_id": "c1", + "amount_ore": 2000, + "shares": { + "a": 1000, + "b": 1000 + } + }, + { + "date": "2026-11-30", + "account_id": "y", + "category_id": "c2", + "amount_ore": 3000, + "shares": { + "a": 3000, + "b": 0 + } + }, + { + "date": "2026-12-01", + "account_id": "x", + "category_id": "c2", + "amount_ore": 4000, + "shares": { + "a": 1000, + "b": 3000 + } + }, + { + "date": "2027-02-10", + "account_id": "y", + "category_id": "c1", + "amount_ore": 500, + "shares": { + "a": 250, + "b": 250 + } + }, + { + "date": "2027-02-11", + "account_id": "x", + "category_id": "c1", + "amount_ore": 700, + "shares": { + "a": 350, + "b": 350 + } + } + ], + "range": { + "start": "2026-11-15", + "end_inclusive": "2027-02-10" + }, + "granularity": "month", + "measure": { + "share": "b" + }, + "group_by": "category" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-11", + "start": "2026-11-15", + "end_inclusive": "2026-11-30", + "totals": { + "c1": 1000 + }, + "total": 1000 + }, + { + "key": "2026-12", + "start": "2026-12-01", + "end_inclusive": "2026-12-31", + "totals": { + "c2": 3000 + }, + "total": 3000 + }, + { + "key": "2027-01", + "start": "2027-01-01", + "end_inclusive": "2027-01-31", + "totals": {}, + "total": 0 + }, + { + "key": "2027-02", + "start": "2027-02-01", + "end_inclusive": "2027-02-10", + "totals": { + "c1": 250 + }, + "total": 250 + } + ], + "group_totals": { + "c1": 1250, + "c2": 3000 + }, + "grand_total": 4250 + } + } + }, + { + "name": "share of a user absent from every shares map counts 0", + "input": { + "expenses": [ + { + "date": "2026-11-14", + "account_id": "x", + "category_id": "c1", + "amount_ore": 1000, + "shares": { + "a": 500, + "b": 500 + } + }, + { + "date": "2026-11-15", + "account_id": "x", + "category_id": "c1", + "amount_ore": 2000, + "shares": { + "a": 1000, + "b": 1000 + } + }, + { + "date": "2026-11-30", + "account_id": "y", + "category_id": "c2", + "amount_ore": 3000, + "shares": { + "a": 3000, + "b": 0 + } + }, + { + "date": "2026-12-01", + "account_id": "x", + "category_id": "c2", + "amount_ore": 4000, + "shares": { + "a": 1000, + "b": 3000 + } + }, + { + "date": "2027-02-10", + "account_id": "y", + "category_id": "c1", + "amount_ore": 500, + "shares": { + "a": 250, + "b": 250 + } + }, + { + "date": "2027-02-11", + "account_id": "x", + "category_id": "c1", + "amount_ore": 700, + "shares": { + "a": 350, + "b": 350 + } + } + ], + "range": { + "start": "2026-11-15", + "end_inclusive": "2026-12-31" + }, + "granularity": "month", + "measure": { + "share": "z" + }, + "group_by": "category" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-11", + "start": "2026-11-15", + "end_inclusive": "2026-11-30", + "totals": {}, + "total": 0 + }, + { + "key": "2026-12", + "start": "2026-12-01", + "end_inclusive": "2026-12-31", + "totals": {}, + "total": 0 + } + ], + "group_totals": {}, + "grand_total": 0 + } + } + }, + { + "name": "weekly across the 2026/2027 boundary: ISO week 2026-W53 runs 28 Dec 2026 to 3 Jan 2027; first and last weeks clipped, empty W52 kept", + "input": { + "expenses": [ + { + "date": "2026-12-19", + "account_id": "x", + "category_id": "c1", + "amount_ore": 50, + "shares": { + "a": 25, + "b": 25 + } + }, + { + "date": "2026-12-20", + "account_id": "x", + "category_id": "c1", + "amount_ore": 100, + "shares": { + "a": 50, + "b": 50 + } + }, + { + "date": "2026-12-31", + "account_id": "x", + "category_id": "c1", + "amount_ore": 200, + "shares": { + "a": 100, + "b": 100 + } + }, + { + "date": "2027-01-01", + "account_id": "y", + "category_id": "c2", + "amount_ore": 300, + "shares": { + "a": 300, + "b": 0 + } + }, + { + "date": "2027-01-03", + "account_id": "y", + "category_id": "c2", + "amount_ore": 400, + "shares": { + "a": 0, + "b": 400 + } + }, + { + "date": "2027-01-04", + "account_id": "x", + "category_id": "c1", + "amount_ore": 500, + "shares": { + "a": 250, + "b": 250 + } + }, + { + "date": "2027-01-12", + "account_id": "y", + "category_id": "c2", + "amount_ore": 600, + "shares": { + "a": 300, + "b": 300 + } + }, + { + "date": "2027-01-13", + "account_id": "y", + "category_id": "c2", + "amount_ore": 900, + "shares": { + "a": 450, + "b": 450 + } + } + ], + "range": { + "start": "2026-12-20", + "end_inclusive": "2027-01-12" + }, + "granularity": "week", + "measure": "household", + "group_by": "category" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-W51", + "start": "2026-12-20", + "end_inclusive": "2026-12-20", + "totals": { + "c1": 100 + }, + "total": 100 + }, + { + "key": "2026-W52", + "start": "2026-12-21", + "end_inclusive": "2026-12-27", + "totals": {}, + "total": 0 + }, + { + "key": "2026-W53", + "start": "2026-12-28", + "end_inclusive": "2027-01-03", + "totals": { + "c1": 200, + "c2": 700 + }, + "total": 900 + }, + { + "key": "2027-W01", + "start": "2027-01-04", + "end_inclusive": "2027-01-10", + "totals": { + "c1": 500 + }, + "total": 500 + }, + { + "key": "2027-W02", + "start": "2027-01-11", + "end_inclusive": "2027-01-12", + "totals": { + "c2": 600 + }, + "total": 600 + } + ], + "group_totals": { + "c1": 800, + "c2": 1300 + }, + "grand_total": 2100 + } + } + }, + { + "name": "weekly across the 2026/2027 boundary, share of b, by account", + "input": { + "expenses": [ + { + "date": "2026-12-19", + "account_id": "x", + "category_id": "c1", + "amount_ore": 50, + "shares": { + "a": 25, + "b": 25 + } + }, + { + "date": "2026-12-20", + "account_id": "x", + "category_id": "c1", + "amount_ore": 100, + "shares": { + "a": 50, + "b": 50 + } + }, + { + "date": "2026-12-31", + "account_id": "x", + "category_id": "c1", + "amount_ore": 200, + "shares": { + "a": 100, + "b": 100 + } + }, + { + "date": "2027-01-01", + "account_id": "y", + "category_id": "c2", + "amount_ore": 300, + "shares": { + "a": 300, + "b": 0 + } + }, + { + "date": "2027-01-03", + "account_id": "y", + "category_id": "c2", + "amount_ore": 400, + "shares": { + "a": 0, + "b": 400 + } + }, + { + "date": "2027-01-04", + "account_id": "x", + "category_id": "c1", + "amount_ore": 500, + "shares": { + "a": 250, + "b": 250 + } + }, + { + "date": "2027-01-12", + "account_id": "y", + "category_id": "c2", + "amount_ore": 600, + "shares": { + "a": 300, + "b": 300 + } + }, + { + "date": "2027-01-13", + "account_id": "y", + "category_id": "c2", + "amount_ore": 900, + "shares": { + "a": 450, + "b": 450 + } + } + ], + "range": { + "start": "2026-12-20", + "end_inclusive": "2027-01-12" + }, + "granularity": "week", + "measure": { + "share": "b" + }, + "group_by": "account" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-W51", + "start": "2026-12-20", + "end_inclusive": "2026-12-20", + "totals": { + "x": 50 + }, + "total": 50 + }, + { + "key": "2026-W52", + "start": "2026-12-21", + "end_inclusive": "2026-12-27", + "totals": {}, + "total": 0 + }, + { + "key": "2026-W53", + "start": "2026-12-28", + "end_inclusive": "2027-01-03", + "totals": { + "x": 100, + "y": 400 + }, + "total": 500 + }, + { + "key": "2027-W01", + "start": "2027-01-04", + "end_inclusive": "2027-01-10", + "totals": { + "x": 250 + }, + "total": 250 + }, + { + "key": "2027-W02", + "start": "2027-01-11", + "end_inclusive": "2027-01-12", + "totals": { + "y": 300 + }, + "total": 300 + } + ], + "group_totals": { + "x": 400, + "y": 700 + }, + "grand_total": 1100 + } + } + }, + { + "name": "weekly where the ISO year is ahead of the calendar year: 30 Dec 2024 is in 2025-W01", + "input": { + "expenses": [ + { + "date": "2024-12-29", + "account_id": "x", + "category_id": "c1", + "amount_ore": 100, + "shares": { + "a": 50, + "b": 50 + } + }, + { + "date": "2024-12-30", + "account_id": "y", + "category_id": "c1", + "amount_ore": 200, + "shares": { + "a": 100, + "b": 100 + } + }, + { + "date": "2025-01-06", + "account_id": "x", + "category_id": "c2", + "amount_ore": 300, + "shares": { + "a": 150, + "b": 150 + } + } + ], + "range": { + "start": "2024-12-28", + "end_inclusive": "2025-01-06" + }, + "granularity": "week", + "measure": "household", + "group_by": "account" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2024-W52", + "start": "2024-12-28", + "end_inclusive": "2024-12-29", + "totals": { + "x": 100 + }, + "total": 100 + }, + { + "key": "2025-W01", + "start": "2024-12-30", + "end_inclusive": "2025-01-05", + "totals": { + "y": 200 + }, + "total": 200 + }, + { + "key": "2025-W02", + "start": "2025-01-06", + "end_inclusive": "2025-01-06", + "totals": { + "x": 300 + }, + "total": 300 + } + ], + "group_totals": { + "x": 400, + "y": 200 + }, + "grand_total": 600 + } + } + }, + { + "name": "month boundaries in a leap year: last and first days land in their own months, 29 Feb counted", + "input": { + "expenses": [ + { + "date": "2028-01-31", + "account_id": "x", + "category_id": "c1", + "amount_ore": 100, + "shares": { + "a": 50, + "b": 50 + } + }, + { + "date": "2028-02-01", + "account_id": "x", + "category_id": "c1", + "amount_ore": 200, + "shares": { + "a": 100, + "b": 100 + } + }, + { + "date": "2028-02-29", + "account_id": "x", + "category_id": "c2", + "amount_ore": 300, + "shares": { + "a": 150, + "b": 150 + } + }, + { + "date": "2028-03-01", + "account_id": "x", + "category_id": "c1", + "amount_ore": 400, + "shares": { + "a": 200, + "b": 200 + } + } + ], + "range": { + "start": "2028-01-31", + "end_inclusive": "2028-03-01" + }, + "granularity": "month", + "measure": "household", + "group_by": "category" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2028-01", + "start": "2028-01-31", + "end_inclusive": "2028-01-31", + "totals": { + "c1": 100 + }, + "total": 100 + }, + { + "key": "2028-02", + "start": "2028-02-01", + "end_inclusive": "2028-02-29", + "totals": { + "c1": 200, + "c2": 300 + }, + "total": 500 + }, + { + "key": "2028-03", + "start": "2028-03-01", + "end_inclusive": "2028-03-01", + "totals": { + "c1": 400 + }, + "total": 400 + } + ], + "group_totals": { + "c1": 700, + "c2": 300 + }, + "grand_total": 1000 + } + } + }, + { + "name": "month boundaries in a common year: February has 28 days", + "input": { + "expenses": [ + { + "date": "2027-02-28", + "account_id": "x", + "category_id": "c1", + "amount_ore": 100, + "shares": { + "a": 50, + "b": 50 + } + }, + { + "date": "2027-03-01", + "account_id": "x", + "category_id": "c1", + "amount_ore": 200, + "shares": { + "a": 100, + "b": 100 + } + } + ], + "range": { + "start": "2027-02-01", + "end_inclusive": "2027-03-31" + }, + "granularity": "month", + "measure": "household", + "group_by": "category" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2027-02", + "start": "2027-02-01", + "end_inclusive": "2027-02-28", + "totals": { + "c1": 100 + }, + "total": 100 + }, + { + "key": "2027-03", + "start": "2027-03-01", + "end_inclusive": "2027-03-31", + "totals": { + "c1": 200 + }, + "total": 200 + } + ], + "group_totals": { + "c1": 300 + }, + "grand_total": 300 + } + } + }, + { + "name": "a single-day range with no expenses: one empty, clipped bucket", + "input": { + "expenses": [], + "range": { + "start": "2026-10-17", + "end_inclusive": "2026-10-17" + }, + "granularity": "month", + "measure": "household", + "group_by": "category" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-10", + "start": "2026-10-17", + "end_inclusive": "2026-10-17", + "totals": {}, + "total": 0 + } + ], + "group_totals": {}, + "grand_total": 0 + } + } + }, + { + "name": "weekly with no expenses: whole weeks, all empty", + "input": { + "expenses": [], + "range": { + "start": "2026-10-12", + "end_inclusive": "2026-10-25" + }, + "granularity": "week", + "measure": "household", + "group_by": "category" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-W42", + "start": "2026-10-12", + "end_inclusive": "2026-10-18", + "totals": {}, + "total": 0 + }, + { + "key": "2026-W43", + "start": "2026-10-19", + "end_inclusive": "2026-10-25", + "totals": {}, + "total": 0 + } + ], + "group_totals": {}, + "grand_total": 0 + } + } + }, + { + "name": "several expenses in the same group and bucket are summed", + "input": { + "expenses": [ + { + "date": "2026-10-01", + "account_id": "x", + "category_id": "c1", + "amount_ore": 100, + "shares": { + "a": 50, + "b": 50 + } + }, + { + "date": "2026-10-17", + "account_id": "y", + "category_id": "c1", + "amount_ore": 250, + "shares": { + "a": 125, + "b": 125 + } + }, + { + "date": "2026-10-31", + "account_id": "x", + "category_id": "c1", + "amount_ore": 1, + "shares": { + "a": 1, + "b": 0 + } + } + ], + "range": { + "start": "2026-10-01", + "end_inclusive": "2026-10-31" + }, + "granularity": "month", + "measure": "household", + "group_by": "category" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-10", + "start": "2026-10-01", + "end_inclusive": "2026-10-31", + "totals": { + "c1": 351 + }, + "total": 351 + } + ], + "group_totals": { + "c1": 351 + }, + "grand_total": 351 + } + } + }, + { + "name": "tombstoned rows excluded: callers pass only non-deleted expenses (AC-14.9); archived accounts and categories are ordinary ids and are counted", + "input": { + "expenses": [ + { + "date": "2026-10-05", + "account_id": "archived-account", + "category_id": "archived-category", + "amount_ore": 1200, + "shares": { + "a": 600, + "b": 600 + } + } + ], + "range": { + "start": "2026-10-01", + "end_inclusive": "2026-10-31" + }, + "granularity": "month", + "measure": "household", + "group_by": "account" + }, + "expected": { + "ok": { + "buckets": [ + { + "key": "2026-10", + "start": "2026-10-01", + "end_inclusive": "2026-10-31", + "totals": { + "archived-account": 1200 + }, + "total": 1200 + } + ], + "group_totals": { + "archived-account": 1200 + }, + "grand_total": 1200 + } + } + } + ] +} \ No newline at end of file diff --git a/contracts/test-vectors/validate_settlement.json b/contracts/test-vectors/validate_settlement.json new file mode 100644 index 0000000..598c15b --- /dev/null +++ b/contracts/test-vectors/validate_settlement.json @@ -0,0 +1,1085 @@ +{ + "format": 1, + "function": "validate_settlement", + "cases": [ + { + "name": "valid: one allocation", + "input": { + "amount_ore": 50000, + "from_user_id": "b", + "to_user_id": "a", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 50000 + } + ] + }, + "expected": { + "ok": null + } + }, + { + "name": "valid: three allocations summing to the amount", + "input": { + "amount_ore": 60000, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 30000 + }, + { + "account_id": "y", + "amount_ore": 20000 + }, + { + "account_id": "z", + "amount_ore": 10000 + } + ] + }, + "expected": { + "ok": null + } + }, + { + "name": "valid: 50 allocations (the maximum)", + "input": { + "amount_ore": 5000, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "acc01", + "amount_ore": 100 + }, + { + "account_id": "acc02", + "amount_ore": 100 + }, + { + "account_id": "acc03", + "amount_ore": 100 + }, + { + "account_id": "acc04", + "amount_ore": 100 + }, + { + "account_id": "acc05", + "amount_ore": 100 + }, + { + "account_id": "acc06", + "amount_ore": 100 + }, + { + "account_id": "acc07", + "amount_ore": 100 + }, + { + "account_id": "acc08", + "amount_ore": 100 + }, + { + "account_id": "acc09", + "amount_ore": 100 + }, + { + "account_id": "acc10", + "amount_ore": 100 + }, + { + "account_id": "acc11", + "amount_ore": 100 + }, + { + "account_id": "acc12", + "amount_ore": 100 + }, + { + "account_id": "acc13", + "amount_ore": 100 + }, + { + "account_id": "acc14", + "amount_ore": 100 + }, + { + "account_id": "acc15", + "amount_ore": 100 + }, + { + "account_id": "acc16", + "amount_ore": 100 + }, + { + "account_id": "acc17", + "amount_ore": 100 + }, + { + "account_id": "acc18", + "amount_ore": 100 + }, + { + "account_id": "acc19", + "amount_ore": 100 + }, + { + "account_id": "acc20", + "amount_ore": 100 + }, + { + "account_id": "acc21", + "amount_ore": 100 + }, + { + "account_id": "acc22", + "amount_ore": 100 + }, + { + "account_id": "acc23", + "amount_ore": 100 + }, + { + "account_id": "acc24", + "amount_ore": 100 + }, + { + "account_id": "acc25", + "amount_ore": 100 + }, + { + "account_id": "acc26", + "amount_ore": 100 + }, + { + "account_id": "acc27", + "amount_ore": 100 + }, + { + "account_id": "acc28", + "amount_ore": 100 + }, + { + "account_id": "acc29", + "amount_ore": 100 + }, + { + "account_id": "acc30", + "amount_ore": 100 + }, + { + "account_id": "acc31", + "amount_ore": 100 + }, + { + "account_id": "acc32", + "amount_ore": 100 + }, + { + "account_id": "acc33", + "amount_ore": 100 + }, + { + "account_id": "acc34", + "amount_ore": 100 + }, + { + "account_id": "acc35", + "amount_ore": 100 + }, + { + "account_id": "acc36", + "amount_ore": 100 + }, + { + "account_id": "acc37", + "amount_ore": 100 + }, + { + "account_id": "acc38", + "amount_ore": 100 + }, + { + "account_id": "acc39", + "amount_ore": 100 + }, + { + "account_id": "acc40", + "amount_ore": 100 + }, + { + "account_id": "acc41", + "amount_ore": 100 + }, + { + "account_id": "acc42", + "amount_ore": 100 + }, + { + "account_id": "acc43", + "amount_ore": 100 + }, + { + "account_id": "acc44", + "amount_ore": 100 + }, + { + "account_id": "acc45", + "amount_ore": 100 + }, + { + "account_id": "acc46", + "amount_ore": 100 + }, + { + "account_id": "acc47", + "amount_ore": 100 + }, + { + "account_id": "acc48", + "amount_ore": 100 + }, + { + "account_id": "acc49", + "amount_ore": 100 + }, + { + "account_id": "acc50", + "amount_ore": 100 + } + ] + }, + "expected": { + "ok": null + } + }, + { + "name": "valid: amount 1 000 000 000 (the maximum)", + "input": { + "amount_ore": 1000000000, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 1000000000 + } + ] + }, + "expected": { + "ok": null + } + }, + { + "name": "amount 0", + "input": { + "amount_ore": 0, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 0 + } + ] + }, + "expected": { + "error": "amount_out_of_range" + } + }, + { + "name": "amount 1 000 000 001", + "input": { + "amount_ore": 1000000001, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 1000000001 + } + ] + }, + "expected": { + "error": "amount_out_of_range" + } + }, + { + "name": "from equals to", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "a", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 500 + } + ] + }, + "expected": { + "error": "same_user" + } + }, + { + "name": "from user unknown", + "input": { + "amount_ore": 500, + "from_user_id": "x", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 500 + } + ] + }, + "expected": { + "error": "user_unknown" + } + }, + { + "name": "to user unknown", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "x", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 500 + } + ] + }, + "expected": { + "error": "user_unknown" + } + }, + { + "name": "no allocations", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [] + }, + "expected": { + "error": "no_allocations" + } + }, + { + "name": "51 allocations", + "input": { + "amount_ore": 5100, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "acc01", + "amount_ore": 100 + }, + { + "account_id": "acc02", + "amount_ore": 100 + }, + { + "account_id": "acc03", + "amount_ore": 100 + }, + { + "account_id": "acc04", + "amount_ore": 100 + }, + { + "account_id": "acc05", + "amount_ore": 100 + }, + { + "account_id": "acc06", + "amount_ore": 100 + }, + { + "account_id": "acc07", + "amount_ore": 100 + }, + { + "account_id": "acc08", + "amount_ore": 100 + }, + { + "account_id": "acc09", + "amount_ore": 100 + }, + { + "account_id": "acc10", + "amount_ore": 100 + }, + { + "account_id": "acc11", + "amount_ore": 100 + }, + { + "account_id": "acc12", + "amount_ore": 100 + }, + { + "account_id": "acc13", + "amount_ore": 100 + }, + { + "account_id": "acc14", + "amount_ore": 100 + }, + { + "account_id": "acc15", + "amount_ore": 100 + }, + { + "account_id": "acc16", + "amount_ore": 100 + }, + { + "account_id": "acc17", + "amount_ore": 100 + }, + { + "account_id": "acc18", + "amount_ore": 100 + }, + { + "account_id": "acc19", + "amount_ore": 100 + }, + { + "account_id": "acc20", + "amount_ore": 100 + }, + { + "account_id": "acc21", + "amount_ore": 100 + }, + { + "account_id": "acc22", + "amount_ore": 100 + }, + { + "account_id": "acc23", + "amount_ore": 100 + }, + { + "account_id": "acc24", + "amount_ore": 100 + }, + { + "account_id": "acc25", + "amount_ore": 100 + }, + { + "account_id": "acc26", + "amount_ore": 100 + }, + { + "account_id": "acc27", + "amount_ore": 100 + }, + { + "account_id": "acc28", + "amount_ore": 100 + }, + { + "account_id": "acc29", + "amount_ore": 100 + }, + { + "account_id": "acc30", + "amount_ore": 100 + }, + { + "account_id": "acc31", + "amount_ore": 100 + }, + { + "account_id": "acc32", + "amount_ore": 100 + }, + { + "account_id": "acc33", + "amount_ore": 100 + }, + { + "account_id": "acc34", + "amount_ore": 100 + }, + { + "account_id": "acc35", + "amount_ore": 100 + }, + { + "account_id": "acc36", + "amount_ore": 100 + }, + { + "account_id": "acc37", + "amount_ore": 100 + }, + { + "account_id": "acc38", + "amount_ore": 100 + }, + { + "account_id": "acc39", + "amount_ore": 100 + }, + { + "account_id": "acc40", + "amount_ore": 100 + }, + { + "account_id": "acc41", + "amount_ore": 100 + }, + { + "account_id": "acc42", + "amount_ore": 100 + }, + { + "account_id": "acc43", + "amount_ore": 100 + }, + { + "account_id": "acc44", + "amount_ore": 100 + }, + { + "account_id": "acc45", + "amount_ore": 100 + }, + { + "account_id": "acc46", + "amount_ore": 100 + }, + { + "account_id": "acc47", + "amount_ore": 100 + }, + { + "account_id": "acc48", + "amount_ore": 100 + }, + { + "account_id": "acc49", + "amount_ore": 100 + }, + { + "account_id": "acc50", + "amount_ore": 100 + }, + { + "account_id": "acc51", + "amount_ore": 100 + } + ] + }, + "expected": { + "error": "too_many_allocations" + } + }, + { + "name": "an allocation of 0", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 500 + }, + { + "account_id": "y", + "amount_ore": 0 + } + ] + }, + "expected": { + "error": "allocation_not_positive" + } + }, + { + "name": "a negative allocation", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 600 + }, + { + "account_id": "y", + "amount_ore": -100 + } + ] + }, + "expected": { + "error": "allocation_not_positive" + } + }, + { + "name": "the same account twice", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 200 + }, + { + "account_id": "x", + "amount_ore": 300 + } + ] + }, + "expected": { + "error": "duplicate_account" + } + }, + { + "name": "allocations sum below the amount", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 200 + }, + { + "account_id": "y", + "amount_ore": 299 + } + ] + }, + "expected": { + "error": "allocation_sum_mismatch" + } + }, + { + "name": "allocations sum above the amount", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 200 + }, + { + "account_id": "y", + "amount_ore": 301 + } + ] + }, + "expected": { + "error": "allocation_sum_mismatch" + } + }, + { + "name": "error order: amount before same user", + "input": { + "amount_ore": 0, + "from_user_id": "a", + "to_user_id": "a", + "user_ids": [ + "a", + "b" + ], + "allocations": [] + }, + "expected": { + "error": "amount_out_of_range" + } + }, + { + "name": "error order: same user before user unknown", + "input": { + "amount_ore": 500, + "from_user_id": "x", + "to_user_id": "x", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 500 + } + ] + }, + "expected": { + "error": "same_user" + } + }, + { + "name": "error order: user unknown before no allocations", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "x", + "user_ids": [ + "a", + "b" + ], + "allocations": [] + }, + "expected": { + "error": "user_unknown" + } + }, + { + "name": "error order: too many allocations before allocation not positive", + "input": { + "amount_ore": 5100, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "acc01", + "amount_ore": 0 + }, + { + "account_id": "acc02", + "amount_ore": 102 + }, + { + "account_id": "acc03", + "amount_ore": 102 + }, + { + "account_id": "acc04", + "amount_ore": 102 + }, + { + "account_id": "acc05", + "amount_ore": 102 + }, + { + "account_id": "acc06", + "amount_ore": 102 + }, + { + "account_id": "acc07", + "amount_ore": 102 + }, + { + "account_id": "acc08", + "amount_ore": 102 + }, + { + "account_id": "acc09", + "amount_ore": 102 + }, + { + "account_id": "acc10", + "amount_ore": 102 + }, + { + "account_id": "acc11", + "amount_ore": 102 + }, + { + "account_id": "acc12", + "amount_ore": 102 + }, + { + "account_id": "acc13", + "amount_ore": 102 + }, + { + "account_id": "acc14", + "amount_ore": 102 + }, + { + "account_id": "acc15", + "amount_ore": 102 + }, + { + "account_id": "acc16", + "amount_ore": 102 + }, + { + "account_id": "acc17", + "amount_ore": 102 + }, + { + "account_id": "acc18", + "amount_ore": 102 + }, + { + "account_id": "acc19", + "amount_ore": 102 + }, + { + "account_id": "acc20", + "amount_ore": 102 + }, + { + "account_id": "acc21", + "amount_ore": 102 + }, + { + "account_id": "acc22", + "amount_ore": 102 + }, + { + "account_id": "acc23", + "amount_ore": 102 + }, + { + "account_id": "acc24", + "amount_ore": 102 + }, + { + "account_id": "acc25", + "amount_ore": 102 + }, + { + "account_id": "acc26", + "amount_ore": 102 + }, + { + "account_id": "acc27", + "amount_ore": 102 + }, + { + "account_id": "acc28", + "amount_ore": 102 + }, + { + "account_id": "acc29", + "amount_ore": 102 + }, + { + "account_id": "acc30", + "amount_ore": 102 + }, + { + "account_id": "acc31", + "amount_ore": 102 + }, + { + "account_id": "acc32", + "amount_ore": 102 + }, + { + "account_id": "acc33", + "amount_ore": 102 + }, + { + "account_id": "acc34", + "amount_ore": 102 + }, + { + "account_id": "acc35", + "amount_ore": 102 + }, + { + "account_id": "acc36", + "amount_ore": 102 + }, + { + "account_id": "acc37", + "amount_ore": 102 + }, + { + "account_id": "acc38", + "amount_ore": 102 + }, + { + "account_id": "acc39", + "amount_ore": 102 + }, + { + "account_id": "acc40", + "amount_ore": 102 + }, + { + "account_id": "acc41", + "amount_ore": 102 + }, + { + "account_id": "acc42", + "amount_ore": 102 + }, + { + "account_id": "acc43", + "amount_ore": 102 + }, + { + "account_id": "acc44", + "amount_ore": 102 + }, + { + "account_id": "acc45", + "amount_ore": 102 + }, + { + "account_id": "acc46", + "amount_ore": 102 + }, + { + "account_id": "acc47", + "amount_ore": 102 + }, + { + "account_id": "acc48", + "amount_ore": 102 + }, + { + "account_id": "acc49", + "amount_ore": 102 + }, + { + "account_id": "acc50", + "amount_ore": 102 + }, + { + "account_id": "acc51", + "amount_ore": 102 + } + ] + }, + "expected": { + "error": "too_many_allocations" + } + }, + { + "name": "error order: allocation not positive before duplicate account", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 500 + }, + { + "account_id": "x", + "amount_ore": 0 + } + ] + }, + "expected": { + "error": "allocation_not_positive" + } + }, + { + "name": "error order: duplicate account before sum mismatch", + "input": { + "amount_ore": 500, + "from_user_id": "a", + "to_user_id": "b", + "user_ids": [ + "a", + "b" + ], + "allocations": [ + { + "account_id": "x", + "amount_ore": 100 + }, + { + "account_id": "x", + "amount_ore": 100 + } + ] + }, + "expected": { + "error": "duplicate_account" + } + } + ] +} \ No newline at end of file diff --git a/tools/contracts-check.py b/tools/contracts-check.py new file mode 100755 index 0000000..0763001 --- /dev/null +++ b/tools/contracts-check.py @@ -0,0 +1,1159 @@ +#!/usr/bin/env python3 +"""Kvitt contract check (SPEC §11.2 M1 gate). Standard library only, Python 3.9+. + +Checks, one output line each: + * every JSON file under contracts/ parses; + * every test-vector file matches the format of SPEC §14.5.5 and covers the minimum cases; + * a reference implementation of SPEC §9.1, §9.3, §9.4 and §14.5.2/§14.5.4 reproduces every expected value; + * every example under contracts/examples/ matches the wire shapes of SPEC §14.3, §14.4 and §14.6 by key set and type, + plus the money invariants of §9.1 / §9.4 / §14.3.3 (sums, sort orders); + * contracts/schema/server-v1.sql is byte-identical to the DDL in SPEC §14.1, loads in SQLite, has a clean + foreign_key_check and the seed rows; + * contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2 (text check: + the standard library has no YAML parser). + +Exit status 0 when every check passes, 1 otherwise. +Usage: python3 tools/contracts-check.py [repository root] +""" + +import datetime +import json +import os +import re +import sqlite3 +import sys + +ROOT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CONTRACTS = os.path.join(ROOT, "contracts") +FAILURES = [] +PASSES = [0] + + +def report(ok, name, detail=""): + if ok: + PASSES[0] += 1 + print("PASS %s%s" % (name, (": " + detail) if detail else "")) + else: + FAILURES.append(name) + print("FAIL %s: %s" % (name, detail)) + + +def rel(path): + return os.path.relpath(path, ROOT) + + +# ---------------------------------------------------------------------------------------------------------------------- +# Reference implementation of the pure domain functions (SPEC §9.1, §9.3, §9.4, §14.5.2, §14.5.4) +# ---------------------------------------------------------------------------------------------------------------------- + +class DomainError(Exception): + def __init__(self, token): + Exception.__init__(self, token) + self.token = token + + +AMOUNT_MIN, AMOUNT_MAX = 1, 1000000000 +BP_TOTAL = 10000 +MAX_ALLOCATIONS = 50 + + +def compute_split(amount_ore, payer_user_id, user_ids, spec): + """§9.1 and §14.5.2. Errors in SplitError enum order.""" + mode = spec["mode"] + # 1 amount_out_of_range + if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX): + raise DomainError("amount_out_of_range") + # 2 users_invalid + if len(user_ids) != 2 or len(set(user_ids)) != 2: + raise DomainError("users_invalid") + # 3 payer_unknown + if payer_user_id not in user_ids: + raise DomainError("payer_unknown") + if mode == "full": + bearer = spec["bearer_user_id"] + # 4-7 do not apply; 8 bearer_unknown; 9 share_users_mismatch cannot happen + if bearer not in user_ids: + raise DomainError("bearer_unknown") + bp = {u: (BP_TOTAL if u == bearer else 0) for u in user_ids} + return _ratio_rule(amount_ore, payer_user_id, user_ids, bp) + if mode == "ratio": + bp = spec["bp"] + # 4 bp_out_of_range, 5 bp_sum_not_10000 + if any(not (0 <= v <= BP_TOTAL) for v in bp.values()): + raise DomainError("bp_out_of_range") + if sum(bp.values()) != BP_TOTAL: + raise DomainError("bp_sum_not_10000") + # 9 share_users_mismatch + if set(bp.keys()) != set(user_ids): + raise DomainError("share_users_mismatch") + return _ratio_rule(amount_ore, payer_user_id, user_ids, bp) + if mode == "exact": + ore = spec["ore"] + # 6 exact_negative, 7 exact_sum_mismatch + if any(v < 0 for v in ore.values()): + raise DomainError("exact_negative") + if sum(ore.values()) != amount_ore: + raise DomainError("exact_sum_mismatch") + if set(ore.keys()) != set(user_ids): + raise DomainError("share_users_mismatch") + return {u: ore[u] for u in user_ids} + raise ValueError("unknown split mode %r" % mode) + + +def _ratio_rule(amount_ore, payer, user_ids, bp): + non_payer = [u for u in user_ids if u != payer][0] + share_n = (amount_ore * bp[non_payer]) // BP_TOTAL # floor; all operands non-negative + return {non_payer: share_n, payer: amount_ore - share_n} + + +def compute_balances(user_ids, account_ids, expenses, settlements): + """§9.3 and §14.5.2. Inputs hold only non-deleted rows.""" + per = {} + + def acc(a): + if a not in per: + per[a] = {u: 0 for u in user_ids} + return per[a] + + for a in account_ids: + acc(a) + for e in expenses: + m = acc(e["account_id"]) + m[e["payer_user_id"]] = m.get(e["payer_user_id"], 0) + e["amount_ore"] + for u, s in e["shares"].items(): + m[u] = m.get(u, 0) - s + for s in settlements: + for a, amt in s["allocations"].items(): + m = acc(a) + m[s["from_user_id"]] = m.get(s["from_user_id"], 0) + amt + m[s["to_user_id"]] = m.get(s["to_user_id"], 0) - amt + glob = {u: 0 for u in user_ids} + for m in per.values(): + for u, v in m.items(): + glob[u] = glob.get(u, 0) + v + return {"per_account": per, "global": glob} + + +def _account_order(a): + return (a["created_at"], a["account_id"]) + + +def propose_allocation(amount_ore, accounts): + """§9.4 algorithm, §14.5.2 semantics. Errors in AllocationError enum order.""" + if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX): + raise DomainError("amount_out_of_range") + if not accounts: + raise DomainError("no_accounts") + owing = sorted([a for a in accounts if a["payer_net_ore"] < 0], key=_account_order) + allocations = [] + remaining = amount_ore + for a in owing: + if remaining <= 0: + break + x = min(remaining, -a["payer_net_ore"]) + allocations.append({"account_id": a["account_id"], "amount_ore": x}) + remaining -= x + excess = 0 + over = [] + if owing: + if remaining > 0: + allocations[-1]["amount_ore"] += remaining + excess = remaining + over = [allocations[-1]["account_id"]] + else: + active = sorted([a for a in accounts if not a["archived"]], key=_account_order) + target = active[0] if active else sorted(accounts, key=_account_order)[0] + allocations = [{"account_id": target["account_id"], "amount_ore": amount_ore}] + excess = amount_ore + over = [target["account_id"]] + return {"allocations": allocations, "excess_ore": excess, "over_settled_account_ids": over} + + +def validate_settlement(amount_ore, from_user_id, to_user_id, user_ids, allocations): + """§9.4 settlement validity. Errors in SettlementError enum order. Returns None when valid.""" + if not (AMOUNT_MIN <= amount_ore <= AMOUNT_MAX): + raise DomainError("amount_out_of_range") + if from_user_id == to_user_id: + raise DomainError("same_user") + if from_user_id not in user_ids or to_user_id not in user_ids: + raise DomainError("user_unknown") + if not allocations: + raise DomainError("no_allocations") + if len(allocations) > MAX_ALLOCATIONS: + raise DomainError("too_many_allocations") + if any(a["amount_ore"] < 1 for a in allocations): + raise DomainError("allocation_not_positive") + ids = [a["account_id"] for a in allocations] + if len(set(ids)) != len(ids): + raise DomainError("duplicate_account") + if sum(a["amount_ore"] for a in allocations) != amount_ore: + raise DomainError("allocation_sum_mismatch") + return None + + +def over_settled_accounts(allocations, accounts): + """§9.4 over-settle warning, §14.5.2: unknown account counts as net 0.""" + net = {a["account_id"]: a["payer_net_ore"] for a in accounts} + return [a["account_id"] for a in allocations if a["amount_ore"] > max(0, -net.get(a["account_id"], 0))] + + +def _d(s): + return datetime.date(int(s[0:4]), int(s[5:7]), int(s[8:10])) + + +def _month_key(d): + return "%04d-%02d" % (d.year, d.month) + + +def _week_key(d): + y, w, _ = d.isocalendar() + return "%04d-W%02d" % (y, w) + + +def _month_last(d): + nxt = datetime.date(d.year + (d.month // 12), d.month % 12 + 1, 1) + return nxt - datetime.timedelta(days=1) + + +def spend_series(expenses, range_, granularity, measure, group_by): + """§14.5.4 spendSeries.""" + start, end = _d(range_["start"]), _d(range_["end_inclusive"]) + buckets = [] + if granularity == "month": + cur = datetime.date(start.year, start.month, 1) + while cur <= end: + last = _month_last(cur) + buckets.append((_month_key(cur), max(cur, start), min(last, end))) + cur = last + datetime.timedelta(days=1) + keyf = _month_key + elif granularity == "week": + cur = start - datetime.timedelta(days=start.weekday()) + while cur <= end: + last = cur + datetime.timedelta(days=6) + buckets.append((_week_key(cur), max(cur, start), min(last, end))) + cur = last + datetime.timedelta(days=1) + keyf = _week_key + else: + raise ValueError("granularity %r" % granularity) + totals = {k: {} for k, _, _ in buckets} + for e in expenses: + d = _d(e["date"]) + if d < start or d > end: + continue + if measure == "household": + v = e["amount_ore"] + else: + v = e["shares"].get(measure["share"], 0) + g = e["category_id"] if group_by == "category" else e["account_id"] + t = totals[keyf(d)] + t[g] = t.get(g, 0) + v + out = [] + group_totals = {} + for k, s, en in buckets: + t = {g: v for g, v in totals[k].items() if v != 0} + for g, v in t.items(): + group_totals[g] = group_totals.get(g, 0) + v + out.append({"key": k, "start": s.isoformat(), "end_inclusive": en.isoformat(), "totals": t, "total": sum(t.values())}) + group_totals = {g: v for g, v in group_totals.items() if v != 0} + return {"buckets": out, "group_totals": group_totals, "grand_total": sum(group_totals.values())} + + +def resolve_range(preset, today, earliest_expense, custom_start, custom_end): + """§14.5.4 resolveRange.""" + t = _d(today) + + def months_back(n): + m = t.year * 12 + (t.month - 1) - (n - 1) + return datetime.date(m // 12, m % 12 + 1, 1) + + if preset == "this_month": + s, e = datetime.date(t.year, t.month, 1), t + elif preset in ("last_3_months", "last_6_months", "last_12_months"): + s, e = months_back(int(preset.split("_")[1])), t + elif preset == "this_year": + s, e = datetime.date(t.year, 1, 1), t + elif preset == "all_time": + s, e = (_d(earliest_expense) if earliest_expense else t), t + elif preset == "custom": + s, e = _d(custom_start), _d(custom_end) + if s > e: + s, e = e, s + else: + raise ValueError("preset %r" % preset) + return {"start": s.isoformat(), "end_inclusive": e.isoformat()} + + +# ---------------------------------------------------------------------------------------------------------------------- +# Test vectors (§14.5.5) +# ---------------------------------------------------------------------------------------------------------------------- + +SPLIT_ERRORS = ["amount_out_of_range", "users_invalid", "payer_unknown", "bp_out_of_range", "bp_sum_not_10000", + "exact_negative", "exact_sum_mismatch", "bearer_unknown", "share_users_mismatch"] +ALLOCATION_ERRORS = ["amount_out_of_range", "no_accounts"] +SETTLEMENT_ERRORS = ["amount_out_of_range", "same_user", "user_unknown", "no_allocations", "too_many_allocations", + "allocation_not_positive", "duplicate_account", "allocation_sum_mismatch"] +PRESETS = ["this_month", "last_3_months", "last_6_months", "last_12_months", "this_year", "all_time", "custom"] + +VECTOR_FUNCTIONS = { + # function: (input keys = Rust parameter names, or snake_case Kotlin names for the Kotlin-only functions, + # allowed error tokens, reference) + "compute_split": (["amount_ore", "payer_user_id", "user_ids", "spec"], SPLIT_ERRORS, + lambda i: compute_split(i["amount_ore"], i["payer_user_id"], i["user_ids"], i["spec"])), + "compute_balances": (["user_ids", "account_ids", "expenses", "settlements"], [], + lambda i: compute_balances(i["user_ids"], i["account_ids"], i["expenses"], i["settlements"])), + "propose_allocation": (["amount_ore", "accounts"], ALLOCATION_ERRORS, + lambda i: propose_allocation(i["amount_ore"], i["accounts"])), + "validate_settlement": (["amount_ore", "from_user_id", "to_user_id", "user_ids", "allocations"], SETTLEMENT_ERRORS, + lambda i: validate_settlement(i["amount_ore"], i["from_user_id"], i["to_user_id"], + i["user_ids"], i["allocations"])), + "over_settled_accounts": (["allocations", "accounts"], [], + lambda i: over_settled_accounts(i["allocations"], i["accounts"])), + "spend_series": (["expenses", "range", "granularity", "measure", "group_by"], [], + lambda i: spend_series(i["expenses"], i["range"], i["granularity"], i["measure"], i["group_by"])), + "resolve_range": (["preset", "today", "earliest_expense", "custom_start", "custom_end"], [], + lambda i: resolve_range(i["preset"], i["today"], i["earliest_expense"], i["custom_start"], + i["custom_end"])), +} + +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$") +PERIOD_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") +WEEK_RE = re.compile(r"^\d{4}-W(0[1-9]|[1-4]\d|5[0-3])$") +UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + +def is_int(v): + return isinstance(v, int) and not isinstance(v, bool) + + +def valid_date(s): + if not isinstance(s, str) or not DATE_RE.match(s): + return False + try: + _d(s) + return True + except ValueError: + return False + + +def valid_ts(s): + if not isinstance(s, str) or not TS_RE.match(s): + return False + try: + datetime.datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S") + return True + except ValueError: + return False + + +def check_vector_input(fn, inp): + """Type check of a case input against the §14.5.1 types. Returns an error string or None.""" + def int_map(m): + return isinstance(m, dict) and all(isinstance(k, str) and is_int(v) for k, v in m.items()) + + def str_list(x): + return isinstance(x, list) and all(isinstance(v, str) for v in x) + + def position(a): + return (isinstance(a, dict) and set(a) == {"account_id", "created_at", "archived", "payer_net_ore"} + and isinstance(a["account_id"], str) and valid_ts(a["created_at"]) + and isinstance(a["archived"], bool) and is_int(a["payer_net_ore"])) + + def allocation(a): + return isinstance(a, dict) and set(a) == {"account_id", "amount_ore"} and isinstance(a["account_id"], str) and is_int(a["amount_ore"]) + + if fn == "compute_split": + sp = inp["spec"] + if not (is_int(inp["amount_ore"]) and isinstance(inp["payer_user_id"], str) and str_list(inp["user_ids"])): + return "scalar types" + if not isinstance(sp, dict) or sp.get("mode") not in ("ratio", "exact", "full"): + return "spec.mode must be ratio, exact or full" + want = {"ratio": {"mode", "bp"}, "exact": {"mode", "ore"}, "full": {"mode", "bearer_user_id"}}[sp["mode"]] + if set(sp) != want: + return "spec keys %s, expected %s" % (sorted(sp), sorted(want)) + if sp["mode"] == "ratio" and not int_map(sp["bp"]): + return "spec.bp must map user id to int" + if sp["mode"] == "exact" and not int_map(sp["ore"]): + return "spec.ore must map user id to int" + if sp["mode"] == "full" and not isinstance(sp["bearer_user_id"], str): + return "spec.bearer_user_id must be a string" + elif fn == "compute_balances": + if not (str_list(inp["user_ids"]) and str_list(inp["account_ids"])): + return "id lists" + for e in inp["expenses"]: + if not (isinstance(e, dict) and set(e) == {"account_id", "amount_ore", "payer_user_id", "shares"} + and is_int(e["amount_ore"]) and int_map(e["shares"])): + return "expense %r" % (e,) + for s in inp["settlements"]: + if not (isinstance(s, dict) and set(s) == {"from_user_id", "to_user_id", "allocations"} and int_map(s["allocations"])): + return "settlement %r" % (s,) + elif fn == "propose_allocation": + if not is_int(inp["amount_ore"]) or not all(position(a) for a in inp["accounts"]): + return "amount or account positions" + elif fn == "validate_settlement": + if not (is_int(inp["amount_ore"]) and str_list(inp["user_ids"]) and all(allocation(a) for a in inp["allocations"])): + return "amount, user ids or allocations" + elif fn == "over_settled_accounts": + if not (all(allocation(a) for a in inp["allocations"]) and all(position(a) for a in inp["accounts"])): + return "allocations or account positions" + elif fn == "spend_series": + for e in inp["expenses"]: + if not (isinstance(e, dict) and set(e) == {"date", "account_id", "category_id", "amount_ore", "shares"} + and valid_date(e["date"]) and is_int(e["amount_ore"]) and int_map(e["shares"])): + return "expense %r" % (e,) + r = inp["range"] + if not (isinstance(r, dict) and set(r) == {"start", "end_inclusive"} and valid_date(r["start"]) and valid_date(r["end_inclusive"])): + return "range" + if inp["granularity"] not in ("month", "week") or inp["group_by"] not in ("category", "account"): + return "granularity or group_by" + m = inp["measure"] + if not (m == "household" or (isinstance(m, dict) and set(m) == {"share"} and isinstance(m["share"], str))): + return "measure must be \"household\" or {\"share\": id}" + elif fn == "resolve_range": + if inp["preset"] not in PRESETS or not valid_date(inp["today"]): + return "preset or today" + for k in ("earliest_expense", "custom_start", "custom_end"): + if inp[k] is not None and not valid_date(inp[k]): + return k + return None + + +def check_vector_ok_shape(fn, ok): + if fn == "compute_split": + return isinstance(ok, dict) and all(is_int(v) for v in ok.values()) + if fn == "compute_balances": + return isinstance(ok, dict) and set(ok) == {"per_account", "global"} + if fn == "propose_allocation": + return (isinstance(ok, dict) and set(ok) == {"allocations", "excess_ore", "over_settled_account_ids"} + and all(isinstance(a, dict) and set(a) == {"account_id", "amount_ore"} for a in ok["allocations"])) + if fn == "validate_settlement": + return ok is None + if fn == "over_settled_accounts": + return isinstance(ok, list) + if fn == "spend_series": + return (isinstance(ok, dict) and set(ok) == {"buckets", "group_totals", "grand_total"} + and all(isinstance(b, dict) and set(b) == {"key", "start", "end_inclusive", "totals", "total"} for b in ok["buckets"])) + if fn == "resolve_range": + return isinstance(ok, dict) and set(ok) == {"start", "end_inclusive"} + return False + + +def coverage(fn, cases): + """Minimum cases of §14.5.5 that can be checked mechanically. Returns a list of missing items.""" + missing = [] + errors = set(c["expected"].get("error") for c in cases if "error" in c["expected"]) + if fn == "compute_split": + missing += ["error " + e for e in SPLIT_ERRORS if e not in errors] + rows = [(1001, "a", 501, 500), (1, "a", 1, 0), (100, "b", 40, 60), (999, "a", 333, 666), (999, "b", 667, 332), + (50000, "a", 0, 50000), (50000, "a", 50000, 0)] + for amt, payer, sp, sn in rows: + hit = any(c["input"]["amount_ore"] == amt and c["input"]["payer_user_id"] == payer + and c["expected"].get("ok") is not None and c["expected"]["ok"].get(payer) == sp + and sorted(c["expected"]["ok"].values()) == sorted([sp, sn]) for c in cases) + if not hit: + missing.append("§9.1 row %d/%s" % (amt, payer)) + modes = set((c["input"]["spec"]["mode"], "ok" if "ok" in c["expected"] else "err") for c in cases) + for m in [("full", "ok"), ("exact", "ok")]: + if m not in modes: + missing.append("%s %s" % m) + if not any(c["input"]["spec"]["mode"] == "full" and c["input"]["spec"]["bearer_user_id"] == c["input"]["payer_user_id"] + and "ok" in c["expected"] for c in cases): + missing.append("full on payer") + if not any(c["input"]["spec"]["mode"] == "full" and c["input"]["spec"]["bearer_user_id"] != c["input"]["payer_user_id"] + and "ok" in c["expected"] for c in cases): + missing.append("full on non-payer") + amounts = set(c["input"]["amount_ore"] for c in cases) + for a in (0, 1000000001, 1000000000): + if a not in amounts: + missing.append("amount %d" % a) + elif fn == "propose_allocation": + missing += ["error " + e for e in ALLOCATION_ERRORS if e not in errors] + oks = [c["expected"]["ok"] for c in cases if "ok" in c["expected"]] + if not any(o["excess_ore"] > 0 and len(o["allocations"]) > 1 for o in oks): + missing.append("excess added to the last owing account") + if not any(o["excess_ore"] == 0 and o["allocations"] for o in oks): + missing.append("no excess") + elif fn == "validate_settlement": + missing += ["error " + e for e in SETTLEMENT_ERRORS if e not in errors] + valid_sizes = set(len(c["input"]["allocations"]) for c in cases if "ok" in c["expected"]) + for n in (3, 50): + if n not in valid_sizes: + missing.append("valid case with %d allocations" % n) + elif fn == "over_settled_accounts": + oks = [c["expected"]["ok"] for c in cases] + if [] not in oks: + missing.append("none") + if not any(len(o) == 1 for o in oks): + missing.append("one") + elif fn == "spend_series": + seen = set() + for c in cases: + i = c["input"] + seen.add(i["granularity"]) + seen.add("household" if i["measure"] == "household" else "share") + seen.add(i["group_by"]) + for b in c["expected"]["ok"]["buckets"]: + seen.add(b["key"]) + if b["total"] == 0: + seen.add("empty bucket") + for want in ("month", "week", "household", "share", "category", "account", "2026-W53", "empty bucket"): + if want not in seen: + missing.append(want) + elif fn == "resolve_range": + presets = set(c["input"]["preset"] for c in cases) + missing += ["preset " + p for p in PRESETS if p not in presets] + if not any(c["input"]["preset"] == "all_time" and c["input"]["earliest_expense"] is None for c in cases): + missing.append("all_time without earliest expense") + if not any(c["input"]["preset"] == "all_time" and c["input"]["earliest_expense"] is not None for c in cases): + missing.append("all_time with earliest expense") + if not any(c["input"]["preset"] == "custom" and c["input"]["custom_start"] > c["input"]["custom_end"] for c in cases): + missing.append("custom swapped") + if not all(any(c["input"]["preset"] == p and c["input"]["today"] == "2026-10-17" for c in cases) for p in PRESETS if p != "custom"): + missing.append("every preset with today 2026-10-17") + return missing + + +def check_vectors(): + d = os.path.join(CONTRACTS, "test-vectors") + expected_files = set(f + ".json" for f in VECTOR_FUNCTIONS) + present = set(f for f in os.listdir(d) if f.endswith(".json")) if os.path.isdir(d) else set() + report(present == expected_files, "vectors: file set is exactly §14.5.5", + "missing %s, extra %s" % (sorted(expected_files - present), sorted(present - expected_files)) if present != expected_files else "%d files" % len(present)) + counts = {} + for fn in sorted(VECTOR_FUNCTIONS): + path = os.path.join(d, fn + ".json") + if not os.path.exists(path): + continue + try: + with open(path, encoding="utf-8") as f: + doc = json.load(f) + except ValueError as e: + report(False, "vectors %s format" % fn, "does not parse: %s" % e) + continue + keys, errs, ref = VECTOR_FUNCTIONS[fn] + problems = [] + if not isinstance(doc, dict) or set(doc) != {"format", "function", "cases"}: + problems.append("top-level keys must be format, function, cases") + else: + if doc["format"] != 1: + problems.append("format must be 1") + if doc["function"] != fn: + problems.append("function %r does not match file name" % doc["function"]) + if not isinstance(doc["cases"], list) or not doc["cases"]: + problems.append("cases must be a non-empty list") + cases = doc.get("cases") if isinstance(doc, dict) and isinstance(doc.get("cases"), list) else [] + names = set() + for n, c in enumerate(cases): + where = "case %d" % n + if not isinstance(c, dict) or set(c) != {"name", "input", "expected"}: + problems.append("%s: keys must be name, input, expected" % where) + continue + if not isinstance(c["name"], str) or not c["name"]: + problems.append("%s: name must be a non-empty string" % where) + elif c["name"] in names: + problems.append("%s: duplicate name %r" % (where, c["name"])) + names.add(c["name"]) + if not isinstance(c["input"], dict) or set(c["input"]) != set(keys): + problems.append("%s (%s): input keys %s, expected %s" % (where, c["name"], sorted(c["input"]) if isinstance(c["input"], dict) else "?", sorted(keys))) + continue + bad = check_vector_input(fn, c["input"]) + if bad: + problems.append("%s (%s): input %s" % (where, c["name"], bad)) + exp = c["expected"] + if not isinstance(exp, dict) or len(exp) != 1 or list(exp)[0] not in ("ok", "error"): + problems.append("%s (%s): expected must be {\"ok\": …} or {\"error\": …}" % (where, c["name"])) + continue + if "error" in exp and exp["error"] not in errs: + problems.append("%s (%s): error token %r not allowed for %s" % (where, c["name"], exp["error"], fn)) + if "ok" in exp and not check_vector_ok_shape(fn, exp["ok"]): + problems.append("%s (%s): ok value has the wrong shape" % (where, c["name"])) + report(not problems, "vectors %s.json format (§14.5.5)" % fn, "; ".join(problems) if problems else "%d cases" % len(cases)) + if problems: + continue + missing = coverage(fn, cases) + report(not missing, "vectors %s.json minimum cases (§14.5.5)" % fn, "missing: " + ", ".join(missing) if missing else "covered") + mismatches = [] + for c in cases: + try: + got = {"ok": ref(c["input"])} + except DomainError as e: + got = {"error": e.token} + if got != c["expected"]: + mismatches.append("%r: expected %s, reference gives %s" % (c["name"], json.dumps(c["expected"], ensure_ascii=False), json.dumps(got, ensure_ascii=False))) + report(not mismatches, "vectors %s.json reproduced by the reference implementation" % fn, + " | ".join(mismatches) if mismatches else "%d/%d cases" % (len(cases), len(cases))) + counts[fn] = len(cases) + return counts + + +# ---------------------------------------------------------------------------------------------------------------------- +# Wire shapes (§14.3.3, §14.3.4, §14.4.2, §14.4.3, §14.6) +# ---------------------------------------------------------------------------------------------------------------------- +# A type is: "uuid" | "string" | "int" | "bool" | "ts" | "date" | "period" | "any" | "hex32" +# | ("enum", [values]) | ("null", type) | ("array", type, min, max) | "" +# A shape is {"fields": {key: type}, "optional": set of keys that may be absent, "patch": bool (all optional, ≥ 1)} + +def N(t): + return ("null", t) + + +def A(t, lo=0, hi=None): + return ("array", t, lo, hi) + + +def E(*vals): + return ("enum", list(vals)) + + +ROLE = E("admin", "member") +INVITE_KIND = E("admin_setup", "partner", "device") +DEFAULT_KEY = E("groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", "shopping", "other") +SPLIT_MODE = E("default", "ratio", "exact", "full") +ENTITY_TYPE = E("expense", "settlement") +REASON = E("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew") +ERROR_CODES = ["invalid_request", "invite_invalid", "unauthorized", "device_revoked", "forbidden", "not_found", + "household_full", "household_incomplete", "name_taken", "account_not_empty", "category_in_use", + "category_is_default", "cannot_revoke_current_device", "no_accounts", "cursor_ahead", "payload_too_large", + "unsupported_media_type", "validation_failed", "image_invalid", "batch_too_large", "protocol_unsupported", + "rate_limited", "internal", "not_implemented", "unavailable"] +PUSH_TYPES = ["expense_created", "expense_updated", "expense_deleted", "settlement_created", "settlement_updated", + "settlement_deleted", "recurring_expense_created", "sync_batch"] +ICONS = ["shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train", + "local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center", + "medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard", + "celebration", "build", "chair", "subscriptions", "category"] +COLORS = ["green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime"] + + +def S(fields, optional=(), patch=False): + return {"fields": fields, "optional": set(optional), "patch": patch} + + +TEMPLATE_WRITABLE = { + "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string", "notes": "string", + "payer_user_id": "uuid", "split_mode": SPLIT_MODE, "splits": A("TemplateSplit", 0, 2), "day_of_month": "int", + "start_period": "period", "end_period": N("period"), "paused": "bool", +} + +SHAPES = { + # §14.3.3 wire objects + "User": S({"id": "uuid", "role": ROLE, "display_name": "string", "picture_version": N("int"), "created_at": "ts", + "updated_at": "ts", "server_version": "int"}), + "Device": S({"id": "uuid", "user_id": "uuid", "name": "string", "app_version_code": N("int"), "created_at": "ts", + "last_seen_at": "ts", "revoked_at": N("ts"), "is_current": "bool"}), + "Invite": S({"id": "uuid", "kind": INVITE_KIND, "target_user_id": N("uuid"), "code": N("string"), "link": N("string"), + "created_at": "ts", "expires_at": "ts"}), + "Share": S({"user_id": "uuid", "share_bp": "int"}), + "Account": S({"id": "uuid", "name": "string", "default_shares": A("Share", 2, 2), "created_by_user_id": "uuid", + "created_at": "ts", "updated_at": "ts", "archived_at": N("ts"), "deleted_at": N("ts"), "server_version": "int"}), + "Category": S({"id": "uuid", "default_key": N(DEFAULT_KEY), "name": N("string"), "icon": "string", "color": "string", + "sort_order": "int", "created_at": "ts", "updated_at": "ts", "archived_at": N("ts"), "deleted_at": N("ts"), + "server_version": "int"}), + "Split": S({"user_id": "uuid", "share_ore": "int", "share_bp": N("int")}), + "TemplateSplit": S({"user_id": "uuid", "share_bp": N("int"), "share_ore": N("int")}), + "Expense": S({"id": "uuid", "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string", + "notes": "string", "expense_date": "date", "payer_user_id": "uuid", "split_mode": SPLIT_MODE, + "splits": A("Split", 2, 2), "recurring_template_id": N("uuid"), "recurring_period": N("period"), + "created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts", + "deleted_at": N("ts"), "server_version": "int"}), + "Allocation": S({"account_id": "uuid", "amount_ore": "int"}), + "Settlement": S({"id": "uuid", "from_user_id": "uuid", "to_user_id": "uuid", "amount_ore": "int", + "settlement_date": "date", "note": "string", "allocations": A("Allocation", 1, 50), + "created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts", + "deleted_at": N("ts"), "server_version": "int"}), + "RecurringTemplate": S(dict(TEMPLATE_WRITABLE, **{ + "id": "uuid", "last_generated_period": N("period"), "next_occurrence_date": N("date"), + "created_by_user_id": "uuid", "updated_by_user_id": "uuid", "created_at": "ts", "updated_at": "ts", + "deleted_at": N("ts"), "server_version": "int"})), + "AuditChange": S({"field": "string", "old": "any", "new": "any"}), + "AuditEntry": S({"id": "uuid", "entity_type": ENTITY_TYPE, "entity_id": "uuid", "action": E("create", "update", "delete"), + "source": E("app", "recurring", "import"), "actor_user_id": "uuid", "client_at": "ts", "server_at": "ts", + "changes": A("AuditChange"), "server_version": "int"}), + "Error": S({"code": E(*ERROR_CODES), "message": "string", "field": N("string")}), + # §14.3.4 request and response bodies + "Health": S({"status": E("ok", "degraded"), "server_version": "string", "schema_version": "int", "db_ok": "bool"}), + "SetupStatus": S({"initialized": "bool"}), + "AcceptInviteRequest": S({"code": "string", "device_name": "string", "display_name": "string"}, optional=["display_name"]), + "AcceptInviteResponse": S({"token": "string", "user": "User", "device": "Device", "household": A("User", 1, 2)}), + "ApkLatest": S({"version_code": "int", "version_name": "string", "file_name": "string", "size_bytes": "int", + "sha256": "string", "released_at": "ts"}), + "MeResponse": S({"user": "User", "device": "Device", "household": A("User", 1, 2), "public_url": "string"}), + "PatchMeRequest": S({"display_name": "string"}, patch=True), + "CreateInviteRequest": S({"kind": E("partner", "device"), "target_user_id": N("uuid")}, optional=["target_user_id"]), + "InviteList": S({"invites": A("Invite")}), + "DeviceList": S({"devices": A("Device")}), + "PushTokenRequest": S({"fcm_token": N("string")}), + "CreateAccountRequest": S({"name": "string", "default_shares": A("Share", 2, 2)}), + "PatchAccountRequest": S({"name": "string", "default_shares": A("Share", 2, 2)}, patch=True), + "CreateCategoryRequest": S({"name": "string", "icon": "string", "color": "string"}), + "PatchCategoryRequest": S({"name": N("string"), "icon": "string", "color": "string", "sort_order": "int"}, patch=True), + "CreateRecurringTemplateRequest": S(dict(TEMPLATE_WRITABLE)), + "PatchRecurringTemplateRequest": S(dict(TEMPLATE_WRITABLE), patch=True), + "ProposalRequest": S({"payer_user_id": "uuid", "amount_ore": "int", "exclude_settlement_id": N("uuid")}, + optional=["exclude_settlement_id"]), + "ProposalResponse": S({"allocations": A("Allocation"), "excess_ore": "int", "over_settled_account_ids": A("uuid")}), + # §14.4.2 and §14.4.3 sync + "SyncPullResponse": S({"epoch": "hex32", "since": "int", "next_since": "int", "has_more": "bool", "server_time": "ts", + "users": A("User"), "categories": A("Category"), "accounts": A("Account"), + "recurring_templates": A("RecurringTemplate"), "expenses": A("Expense"), + "settlements": A("Settlement"), "audit_entries": A("AuditEntry")}), + "PushedExpense": S({"id": "uuid", "account_id": "uuid", "category_id": "uuid", "amount_ore": "int", "description": "string", + "notes": "string", "expense_date": "date", "payer_user_id": "uuid", "split_mode": SPLIT_MODE, + "splits": A("Split", 2, 2), "created_at": "ts", "updated_at": "ts", "deleted_at": N("ts")}), + "PushedSettlement": S({"id": "uuid", "from_user_id": "uuid", "to_user_id": "uuid", "amount_ore": "int", + "settlement_date": "date", "note": "string", "allocations": A("Allocation", 1, 50), + "created_at": "ts", "updated_at": "ts", "deleted_at": N("ts")}), + "ExpenseMutation": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity": "PushedExpense"}), + "SettlementMutation": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity": "PushedSettlement"}), + "SyncPushRequest": S({"mutations": A("Mutation", 1, 100)}), + "AcceptedExpense": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity_id": "uuid", "status": E("accepted"), + "server_version": "int", "entity": "Expense"}), + "AcceptedSettlement": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity_id": "uuid", "status": E("accepted"), + "server_version": "int", "entity": "Settlement"}), + "RejectedExpense": S({"mutation_id": "uuid", "entity_type": E("expense"), "entity_id": "uuid", "status": E("rejected"), + "reason": REASON, "message": "string", "current": N("Expense")}), + "RejectedSettlement": S({"mutation_id": "uuid", "entity_type": E("settlement"), "entity_id": "uuid", "status": E("rejected"), + "reason": REASON, "message": "string", "current": N("Settlement")}), + "SyncPushResponse": S({"server_time": "ts", "results": A("MutationResult", 1, 100)}), + # §14.6 push payload + "FcmMessage": S({"message": "FcmBody"}), + "FcmBody": S({"token": "string", "android": "FcmAndroid", "data": "FcmData"}), + "FcmAndroid": S({"priority": E("high"), "ttl": E("86400s")}), + "FcmData": S({"v": E("1"), "type": E(*PUSH_TYPES), "entity_type": E("expense", "settlement", ""), "entity_id": "string", + "actor_user_id": "uuid", "count": "string"}), +} + +UNIONS = { + "Mutation": lambda o: {"expense": "ExpenseMutation", "settlement": "SettlementMutation"}.get(o.get("entity_type")), + "MutationResult": lambda o: {("expense", "accepted"): "AcceptedExpense", ("settlement", "accepted"): "AcceptedSettlement", + ("expense", "rejected"): "RejectedExpense", ("settlement", "rejected"): "RejectedSettlement" + }.get((o.get("entity_type"), o.get("status"))), +} + + +def validate(value, typ, path, errs, seen): + """Validate value against typ; append messages to errs; record (shape, object) pairs in seen.""" + if isinstance(typ, tuple): + kind = typ[0] + if kind == "null": + if value is not None: + validate(value, typ[1], path, errs, seen) + return + if kind == "enum": + if value not in typ[1]: + errs.append("%s: %r is not one of %s" % (path, value, typ[1])) + return + if kind == "array": + if not isinstance(value, list): + errs.append("%s: expected an array" % path) + return + lo, hi = typ[2], typ[3] + if len(value) < lo or (hi is not None and len(value) > hi): + errs.append("%s: array length %d outside %s..%s" % (path, len(value), lo, hi)) + for i, v in enumerate(value): + validate(v, typ[1], "%s[%d]" % (path, i), errs, seen) + return + if typ in UNIONS: + if not isinstance(value, dict): + errs.append("%s: expected an object" % path) + return + member = UNIONS[typ](value) + if member is None: + errs.append("%s: no %s variant for this object" % (path, typ)) + return + validate(value, member, path, errs, seen) + return + if typ in SHAPES: + shape = SHAPES[typ] + if not isinstance(value, dict): + errs.append("%s: expected a %s object" % (path, typ)) + return + keys, fields = set(value), set(shape["fields"]) + if shape["patch"]: + if not keys or not keys <= fields: + errs.append("%s: %s keys %s must be a non-empty subset of %s" % (path, typ, sorted(keys), sorted(fields))) + else: + required = fields - shape["optional"] + if not (required <= keys <= fields): + errs.append("%s: %s keys differ: missing %s, unknown %s" % (path, typ, sorted(required - keys), sorted(keys - fields))) + for k in keys & fields: + validate(value[k], shape["fields"][k], "%s.%s" % (path, k), errs, seen) + seen.append((typ, value, path)) + return + ok = { + "uuid": lambda v: isinstance(v, str) and bool(UUID_RE.match(v)), + "string": lambda v: isinstance(v, str), + "int": is_int, + "bool": lambda v: isinstance(v, bool), + "ts": valid_ts, + "date": valid_date, + "period": lambda v: isinstance(v, str) and bool(PERIOD_RE.match(v)), + "hex32": lambda v: isinstance(v, str) and bool(re.match(r"^[0-9a-f]{32}$", v)), + "any": lambda v: True, + }.get(typ) + if ok is None: + errs.append("%s: unknown type %r in the checker" % (path, typ)) + elif not ok(value): + errs.append("%s: %r is not a valid %s" % (path, value, typ)) + + +def semantic(shape, o, path, errs): + """Invariants of §9.1, §9.4, §9.6, §9.7, §14.3.3, §14.6 on objects that passed the shape check.""" + def sorted_by(lst, key): + ids = [x[key] for x in lst] + return ids == sorted(ids) + + if shape in ("Expense", "PushedExpense"): + sp = o["splits"] + if not sorted_by(sp, "user_id"): + errs.append("%s.splits: not sorted by user_id" % path) + if sum(s["share_ore"] for s in sp) != o["amount_ore"] and shape == "Expense": + errs.append("%s.splits: shares do not sum to amount_ore" % path) + if shape == "Expense" and len(sp) == 2: + users = [s["user_id"] for s in sp] + try: + if o["split_mode"] == "exact": + if any(s["share_bp"] is not None for s in sp): + errs.append("%s.splits: share_bp must be null for mode exact" % path) + spec = {"mode": "exact", "ore": {s["user_id"]: s["share_ore"] for s in sp}} + else: + spec = {"mode": "ratio", "bp": {s["user_id"]: s["share_bp"] for s in sp}} + if any(s["share_bp"] is None for s in sp): + raise DomainError("share_bp null in mode %s" % o["split_mode"]) + if o["split_mode"] == "full" and sorted(s["share_bp"] for s in sp) != [0, 10000]: + errs.append("%s.splits: mode full needs bp 10000 and 0" % path) + want = compute_split(o["amount_ore"], o["payer_user_id"], users, spec) + got = {s["user_id"]: s["share_ore"] for s in sp} + if want != got: + errs.append("%s.splits: share_ore %s, §9.1 gives %s" % (path, got, want)) + except DomainError as e: + errs.append("%s.splits: %s" % (path, e.token)) + if shape == "Expense" and (o["recurring_template_id"] is None) != (o["recurring_period"] is None): + errs.append("%s: recurring_template_id and recurring_period must be both null or both set" % path) + if shape in ("Settlement", "PushedSettlement"): + al = o["allocations"] + if not sorted_by(al, "account_id"): + errs.append("%s.allocations: not sorted by account_id" % path) + try: + validate_settlement(o["amount_ore"], o["from_user_id"], o["to_user_id"], [o["from_user_id"], o["to_user_id"]], al) + except DomainError as e: + errs.append("%s: %s" % (path, e.token)) + if shape in ("Account", "CreateAccountRequest", "PatchAccountRequest") and "default_shares" in o: + ds = o["default_shares"] + if not sorted_by(ds, "user_id"): + errs.append("%s.default_shares: not sorted by user_id" % path) + if sum(s["share_bp"] for s in ds) != 10000 or any(not 0 <= s["share_bp"] <= 10000 for s in ds): + errs.append("%s.default_shares: bp must be 0..10000 and sum to 10000" % path) + if shape in ("Category", "CreateCategoryRequest", "PatchCategoryRequest"): + if "icon" in o and o["icon"] not in ICONS: + errs.append("%s.icon: %r is not a §9.7 icon token" % (path, o["icon"])) + if "color" in o and o["color"] not in COLORS: + errs.append("%s.color: %r is not a §9.7 colour token" % (path, o["color"])) + if shape == "Category" and o["name"] is None and o["default_key"] is None: + errs.append("%s: name and default_key cannot both be null" % path) + if shape in ("RecurringTemplate", "CreateRecurringTemplateRequest", "PatchRecurringTemplateRequest") and "splits" in o: + mode, sp = o.get("split_mode"), o["splits"] + if not sorted_by(sp, "user_id"): + errs.append("%s.splits: not sorted by user_id" % path) + for s in sp: + if (s["share_bp"] is None) == (s["share_ore"] is None): + errs.append("%s.splits: exactly one of share_bp / share_ore must be set" % path) + if mode == "default" and sp: + errs.append("%s.splits: must be empty for mode default" % path) + if mode in ("ratio", "full") and (len(sp) != 2 or any(s["share_bp"] is None for s in sp) or sum(s["share_bp"] or 0 for s in sp) != 10000): + errs.append("%s.splits: two bp entries summing to 10000 required for mode %s" % (path, mode)) + if mode == "exact" and (len(sp) != 2 or any(s["share_ore"] is None for s in sp) or sum(s["share_ore"] or 0 for s in sp) != o.get("amount_ore")): + errs.append("%s.splits: two öre entries summing to the amount required for mode exact" % path) + if shape in ("AcceptedExpense", "AcceptedSettlement"): + if o["entity"]["id"] != o["entity_id"] or o["entity"]["server_version"] != o["server_version"]: + errs.append("%s: entity id / server_version do not match the result" % path) + if shape in ("RejectedExpense", "RejectedSettlement") and o["current"] is not None and o["current"]["id"] != o["entity_id"]: + errs.append("%s: current.id does not match entity_id" % path) + if shape == "SyncPullResponse": + for k in ("users", "categories", "accounts", "recurring_templates", "expenses", "settlements", "audit_entries"): + if not sorted_by(o[k], "server_version"): + errs.append("%s.%s: not sorted by server_version" % (path, k)) + versions = [r["server_version"] for k in ("users", "categories", "accounts", "recurring_templates", "expenses", + "settlements", "audit_entries") for r in o[k]] + if versions and max(versions) != o["next_since"]: + errs.append("%s: next_since must be the highest server_version in the page" % path) + if not versions and o["next_since"] != o["since"]: + errs.append("%s: next_since must equal since for an empty page" % path) + if any(v <= o["since"] for v in versions): + errs.append("%s: rows must have server_version > since" % path) + if shape == "AcceptInviteResponse" and not re.match(r"^kvt_[A-Za-z0-9_-]{43}$", o["token"]): + errs.append("%s.token: must be kvt_ + 43 base64url characters (§8.2)" % path) + if shape == "Invite" and o["code"] is not None and not re.match(r"^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{12}$", o["code"]): + errs.append("%s.code: must be 12 Crockford base32 characters (§8.3)" % path) + if shape == "FcmData": + if o["type"] == "sync_batch": + if o["entity_type"] != "" or o["entity_id"] != "" or not re.match(r"^[1-9][0-9]*$", o["count"]): + errs.append("%s: sync_batch needs empty entity_type and entity_id and a positive count" % path) + else: + want = "settlement" if o["type"].startswith("settlement") else "expense" + if o["entity_type"] != want or not UUID_RE.match(o["entity_id"]) or o["count"] != "1": + errs.append("%s: %s needs entity_type %s, a uuid entity_id and count \"1\"" % (path, o["type"], want)) + + +EXAMPLES = { + "E-1-health/response.json": "Health", + "E-2-setup-status/response.json": "SetupStatus", + "E-3-invites-accept/request.json": "AcceptInviteRequest", + "E-3-invites-accept/response.json": "AcceptInviteResponse", + "E-5-apk-latest/response.json": "ApkLatest", + "E-7-me/response.json": "MeResponse", + "E-8-patch-me/request.json": "PatchMeRequest", + "E-8-patch-me/response.json": "User", + "E-9-put-me-picture/response.json": "User", + "E-10-delete-me-picture/response.json": "User", + "E-12-create-invite/request.json": "CreateInviteRequest", + "E-12-create-invite/response.json": "Invite", + "E-13-list-invites/response.json": "InviteList", + "E-15-list-devices/response.json": "DeviceList", + "E-17-push-token/request.json": "PushTokenRequest", + "E-18-create-account/request.json": "CreateAccountRequest", + "E-18-create-account/response.json": "Account", + "E-19-patch-account/request.json": "PatchAccountRequest", + "E-19-patch-account/response.json": "Account", + "E-20-archive-account/response.json": "Account", + "E-21-unarchive-account/response.json": "Account", + "E-22-delete-account/response.json": "Account", + "E-23-create-category/request.json": "CreateCategoryRequest", + "E-23-create-category/response.json": "Category", + "E-24-patch-category/request.json": "PatchCategoryRequest", + "E-24-patch-category/response.json": "Category", + "E-25-archive-category/response.json": "Category", + "E-26-unarchive-category/response.json": "Category", + "E-27-delete-category/response.json": "Category", + "E-28-create-recurring-template/request.json": "CreateRecurringTemplateRequest", + "E-28-create-recurring-template/response.json": "RecurringTemplate", + "E-29-patch-recurring-template/request.json": "PatchRecurringTemplateRequest", + "E-29-patch-recurring-template/response.json": "RecurringTemplate", + "E-30-delete-recurring-template/response.json": "RecurringTemplate", + "E-31-sync-pull/response.json": "SyncPullResponse", + "E-32-sync-push/request.json": "SyncPushRequest", + "E-32-sync-push/response.json": "SyncPushResponse", + "E-33-settlement-proposal/request.json": "ProposalRequest", + "E-33-settlement-proposal/response.json": "ProposalResponse", + "sync/pull-page.json": "SyncPullResponse", + "sync/pull-empty-page.json": "SyncPullResponse", + "sync/push-request.json": "SyncPushRequest", + "sync/push-response.json": "SyncPushResponse", + "sync/push-request-delete.json": "SyncPushRequest", + "sync/push-response-delete.json": "SyncPushResponse", + "sync/push-request-rejections.json": "SyncPushRequest", + "sync/push-response-rejections.json": "SyncPushResponse", +} +EXAMPLES.update({"errors/%s.json" % c: "Error" for c in ERROR_CODES}) +EXAMPLES.update({"push/%s.json" % t: "FcmMessage" for t in PUSH_TYPES}) + + +def check_examples(): + d = os.path.join(CONTRACTS, "examples") + present = set() + for dirpath, _, files in os.walk(d): + for f in files: + present.add(os.path.relpath(os.path.join(dirpath, f), d).replace(os.sep, "/")) + missing, extra = sorted(set(EXAMPLES) - present), sorted(present - set(EXAMPLES)) + report(not missing and not extra, "examples: file set (one per JSON body of §14.3.4, error code, sync and push payload)", + "missing %s, unmapped %s" % (missing, extra) if (missing or extra) else "%d files" % len(present)) + seen_reasons, seen_status = set(), set() + def order(s): + m = re.match(r"^E-(\d+)-", s) + return (0, int(m.group(1)), s) if m else (1, 0, s) + + for name in sorted(set(EXAMPLES) & present, key=order): + shape = EXAMPLES[name] + try: + with open(os.path.join(d, name), encoding="utf-8") as f: + doc = json.load(f) + except ValueError as e: + report(False, "example %s" % name, "does not parse: %s" % e) + continue + errs, seen = [], [] + validate(doc, shape, "$", errs, seen) + if not errs: + for sh, o, p in seen: + semantic(sh, o, p, errs) + if sh.startswith("Rejected"): + seen_reasons.add(o["reason"]) + if sh.startswith("Accepted") or sh.startswith("Rejected"): + seen_status.add(o["status"]) + if name.startswith("errors/") and not errs and doc["code"] != name[len("errors/"):-len(".json")]: + errs.append("code does not match the file name") + if name.startswith("push/") and not errs and doc["message"]["data"]["type"] != name[len("push/"):-len(".json")]: + errs.append("type does not match the file name") + report(not errs, "example %s matches %s" % (name, shape), "; ".join(errs[:5])) + reasons = set(REASON[1]) + report(seen_reasons == reasons and seen_status == {"accepted", "rejected"}, + "examples: every push result status and rejection reason of §14.4.6 appears", + "missing %s" % sorted((reasons - seen_reasons) | ({"accepted", "rejected"} - seen_status)) if seen_reasons != reasons else "") + + +# ---------------------------------------------------------------------------------------------------------------------- +# SQLite schema (§14.1) +# ---------------------------------------------------------------------------------------------------------------------- + +def check_schema(): + path = os.path.join(CONTRACTS, "schema", "server-v1.sql") + if not os.path.exists(path): + report(False, "schema server-v1.sql exists", "missing") + return + with open(path, encoding="utf-8") as f: + sql = f.read() + with open(os.path.join(ROOT, "SPEC.md"), encoding="utf-8") as f: + spec = f.read() + try: + i = spec.index("### 14.1 SQLite schema v1") + j = spec.index("```sql\n", i) + len("```sql\n") + block = spec[j:spec.index("```\n", j)] + report(block == sql, "schema server-v1.sql is byte-identical to the SPEC §14.1 DDL", + "differs from SPEC §14.1" if block != sql else "%d bytes" % len(sql.encode("utf-8"))) + except ValueError: + report(False, "schema server-v1.sql is byte-identical to the SPEC §14.1 DDL", "§14.1 sql block not found in SPEC.md") + if sqlite3.sqlite_version_info < (3, 37, 0): + report(False, "schema server-v1.sql loads in SQLite", "SQLite %s is older than 3.37 (STRICT tables)" % sqlite3.sqlite_version) + return + db = sqlite3.connect(":memory:") + try: + db.execute("PRAGMA foreign_keys = ON") + db.executescript(sql) + report(True, "schema server-v1.sql loads in SQLite", "SQLite %s" % sqlite3.sqlite_version) + except sqlite3.Error as e: + report(False, "schema server-v1.sql loads in SQLite", str(e)) + return + fk = db.execute("PRAGMA foreign_key_check").fetchall() + report(not fk, "schema PRAGMA foreign_key_check is clean", "%r" % fk if fk else "") + tables = set(r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")) + want = {"meta", "sync_sequence", "users", "devices", "invites", "accounts", "account_default_shares", "categories", + "recurring_templates", "recurring_template_splits", "expenses", "expense_splits", "settlements", + "settlement_allocations", "audit_log", "sync_mutations"} + report(tables == want, "schema tables are the 16 of §14.1", "got %s" % sorted(tables) if tables != want else "") + meta = dict(db.execute("SELECT key, value FROM meta")) + seq = db.execute("SELECT id, value FROM sync_sequence").fetchall() + cats = db.execute("SELECT default_key, icon, color, sort_order, server_version FROM categories ORDER BY sort_order").fetchall() + want_cats = [("groceries", "shopping_cart", "green", 10, 1), ("restaurants", "restaurant", "orange", 20, 2), + ("home", "home", "brown", 30, 3), ("utilities", "bolt", "yellow", 40, 4), + ("transport", "directions_car", "blue", 50, 5), ("travel", "flight", "teal", 60, 6), + ("entertainment", "movie", "purple", 70, 7), ("health", "medical_services", "red", 80, 8), + ("shopping", "shopping_bag", "pink", 90, 9), ("other", "category", "grey", 100, 10)] + ok = (meta.get("schema_version") == "1" and re.match(r"^[0-9a-f]{32}$", meta.get("sync_epoch", "")) is not None + and seq == [(1, 10)] and cats == want_cats) + report(ok, "schema seed rows (schema_version 1, sync_epoch, sync_sequence 10, ten default categories of AC-7.2)", + "meta %r, sequence %r, categories %r" % (meta, seq, cats) if not ok else "") + db.close() + + +# ---------------------------------------------------------------------------------------------------------------------- +# OpenAPI (§7, §14.3) — text-level check +# ---------------------------------------------------------------------------------------------------------------------- + +ENDPOINTS = [ + ("E-1", "get", "/health"), ("E-2", "get", "/setup/status"), ("E-3", "post", "/invites/accept"), + ("E-4", "get", "/i/{code}"), ("E-5", "get", "/apk/latest"), ("E-6", "get", "/apk/files/{file_name}"), + ("E-7", "get", "/me"), ("E-8", "patch", "/me"), ("E-9", "put", "/me/picture"), ("E-10", "delete", "/me/picture"), + ("E-11", "get", "/users/{user_id}/picture"), ("E-12", "post", "/invites"), ("E-13", "get", "/invites"), + ("E-14", "delete", "/invites/{invite_id}"), ("E-15", "get", "/devices"), ("E-16", "delete", "/devices/{device_id}"), + ("E-17", "put", "/devices/current/push-token"), ("E-18", "post", "/accounts"), + ("E-19", "patch", "/accounts/{account_id}"), ("E-20", "post", "/accounts/{account_id}/archive"), + ("E-21", "post", "/accounts/{account_id}/unarchive"), ("E-22", "delete", "/accounts/{account_id}"), + ("E-23", "post", "/categories"), ("E-24", "patch", "/categories/{category_id}"), + ("E-25", "post", "/categories/{category_id}/archive"), ("E-26", "post", "/categories/{category_id}/unarchive"), + ("E-27", "delete", "/categories/{category_id}"), ("E-28", "post", "/recurring-templates"), + ("E-29", "patch", "/recurring-templates/{template_id}"), ("E-30", "delete", "/recurring-templates/{template_id}"), + ("E-31", "get", "/sync"), ("E-32", "post", "/sync"), ("E-33", "post", "/settlements/proposal"), +] +UNAUTHENTICATED = {"E-1", "E-2", "E-3", "E-4", "E-5", "E-6"} + + +def check_openapi(): + path = os.path.join(CONTRACTS, "openapi.yaml") + if not os.path.exists(path): + report(False, "openapi.yaml exists", "missing") + return + with open(path, encoding="utf-8") as f: + lines = f.read().splitlines() + text = "\n".join(lines) + head_ok = (re.search(r"^openapi: 3\.1\.0$", text, re.M) and re.search(r"^ title: Kvitt$", text, re.M) + and re.search(r"^ version: '1'$", text, re.M) and "url: https://icehack.asuscomm.com:8443" in text) + report(bool(head_ok), "openapi header (3.1.0, title Kvitt, version 1, one server)", "" if head_ok else "header fields differ from §14.3") + sec_ok = re.search(r"^ device:\n type: http\n scheme: bearer$", text, re.M) is not None + report(sec_ok, "openapi security scheme device = HTTP bearer (§8.2)", "" if sec_ok else "components.securitySchemes.device not found") + # paths: path keys at indent 2, method keys at indent 4, each operation carries `summary: E-n …` + ops, in_paths, cur_path, cur_method = {}, False, None, None + for ln in lines: + if re.match(r"^\S", ln): + in_paths = ln.startswith("paths:") + continue + if not in_paths: + continue + m = re.match(r"^ (/\S*):\s*$", ln) + if m: + cur_path = m.group(1) + continue + m = re.match(r"^ (get|put|post|patch|delete|head|options|trace):\s*$", ln) + if m and cur_path: + cur_method = m.group(1) + ops[(cur_method, cur_path)] = {"eid": None, "security": None} + continue + m = re.match(r"^ summary: '?(E-\d+)\b", ln) + if m and cur_method: + ops[(cur_method, cur_path)]["eid"] = m.group(1) + m = re.match(r"^ security: (.*)$", ln) + if m and cur_method: + ops[(cur_method, cur_path)]["security"] = m.group(1).strip() + want = {(meth, p): eid for eid, meth, p in ENDPOINTS} + got = {k: v["eid"] for k, v in ops.items()} + report(got == want, "openapi operations are exactly E-1..E-33 of §7", + "missing %s, extra %s, wrong ids %s" % (sorted(set(want) - set(got)), sorted(set(got) - set(want)), + sorted(k for k in want if k in got and got[k] != want[k])) if got != want else "%d operations" % len(got)) + bad_sec = sorted(eid for (meth, p), eid in want.items() if (meth, p) in ops and + ((ops[(meth, p)]["security"] == "[]") != (eid in UNAUTHENTICATED))) + report(not bad_sec, "openapi security: E-1..E-6 unauthenticated, all others device", "wrong: %s" % bad_sec if bad_sec else "") + m = re.search(r"^ ErrorCode:\n((?: .*\n|\s*\n)+)", text + "\n", re.M) + codes = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else [] + report(codes == ERROR_CODES, "openapi ErrorCode enum is the exhaustive list of §14.3.2", + "got %s" % codes if codes != ERROR_CODES else "%d codes" % len(codes)) + m = re.search(r"^ RejectionReason:\n((?: .*\n|\s*\n)+)", text + "\n", re.M) + reasons = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else [] + report(reasons == REASON[1], "openapi RejectionReason enum is the exhaustive list of §14.4.6", "got %s" % reasons if reasons != REASON[1] else "") + schemas = set(re.findall(r"^ ([A-Z]\w*):$", text, re.M)) + need = {"User", "Device", "Invite", "Share", "Account", "Category", "Split", "TemplateSplit", "Expense", "Allocation", + "Settlement", "RecurringTemplate", "AuditEntry", "Error"} + report(need <= schemas, "openapi declares every wire object of §14.3.3", "missing %s" % sorted(need - schemas) if not need <= schemas else "") + + +# ---------------------------------------------------------------------------------------------------------------------- + +def check_json_parses(): + bad, n = [], 0 + for dirpath, _, files in os.walk(CONTRACTS): + for f in files: + if f.endswith(".json"): + n += 1 + p = os.path.join(dirpath, f) + try: + with open(p, encoding="utf-8") as fh: + json.load(fh) + except ValueError as e: + bad.append("%s (%s)" % (rel(p), e)) + report(not bad and n > 0, "json: every file under contracts/ parses", "; ".join(bad) if bad else "%d files" % n) + + +def main(): + if not os.path.isdir(CONTRACTS): + report(False, "contracts/ exists", "no such directory: %s" % CONTRACTS) + else: + check_json_parses() + counts = check_vectors() + check_examples() + check_schema() + check_openapi() + if counts: + print("vector cases: " + ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items()))) + print("%d checks passed, %d failed" % (PASSES[0], len(FAILURES))) + return 1 if FAILURES else 0 + + +if __name__ == "__main__": + sys.exit(main())