From b64683984be4efef665e6680eea276ed266530aa Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 26 Oct 2026 04:35:54 +0200 Subject: [PATCH] feat(sdk): recognize the Ban Credit waiver claim verdicts in all six SDKs Claims and bansafe.claim webhooks gain linked_device_interactive and referral_group_send, and claim evidence gains linkedDeviceInteractive and referralGroupSend (polymorfa/polymorfa#845). Rust's closed verdict enums rejected the new values; the other languages now type them too. The contract supplement pins API source 5ccd3dcb and stays unpublished. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++ contracts/README.md | 10 +++ contracts/ban-credit-waivers.json | 72 +++++++++++++++ packages/dotnet/README.md | 7 ++ packages/dotnet/src/Polymorfa.Sdk/BanSafe.cs | 2 +- .../tests/Polymorfa.Sdk.Tests/BanSafeTests.cs | 16 +++- .../tests/Polymorfa.Sdk.Tests/CallsTests.cs | 2 +- .../tests/Polymorfa.Sdk.Tests/Program.cs | 2 + packages/go/README.md | 7 ++ packages/go/ban_credit_waivers_test.go | 41 +++++++++ packages/go/bansafe.go | 20 +++-- packages/php/README.md | 7 ++ packages/php/src/BanSafeTelemetryModels.php | 2 +- packages/php/src/WebhookModels.php | 2 +- packages/php/tests/platform_bansafe.php | 2 + packages/python/README.md | 7 ++ .../src/polymorfa/bansafe_observations.py | 4 + .../python/src/polymorfa/webhook_models.py | 2 + .../python/tests/test_ban_credit_waivers.py | 20 +++++ .../python/tests/test_bansafe_observations.py | 2 + packages/rust/README.md | 7 ++ packages/rust/src/bansafe.rs | 4 +- packages/rust/src/webhook_payloads.rs | 2 + packages/rust/tests/ban_credit_waivers.rs | 30 +++++++ packages/rust/tests/bansafe.rs | 2 +- packages/rust/tests/voip.rs | 2 +- packages/typescript/README.md | 7 +- packages/typescript/src/platform/types.ts | 27 +++++- packages/typescript/src/webhooks/events.ts | 6 +- .../test/ban-credit-waivers-contract.test.ts | 87 +++++++++++++++++++ .../calls-simultaneous-calls-contract.test.ts | 12 ++- 31 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 contracts/ban-credit-waivers.json create mode 100644 packages/go/ban_credit_waivers_test.go create mode 100644 packages/python/tests/test_ban_credit_waivers.py create mode 100644 packages/rust/tests/ban_credit_waivers.rs create mode 100644 packages/typescript/test/ban-credit-waivers-contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e1dea4..b6f6ffb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- All six server SDKs recognize the Ban Credit claim verdicts + `linked_device_interactive` and `referral_group_send` on claim reads and + `bansafe.claim` webhooks, and type claim evidence `linkedDeviceInteractive` + and `referralGroupSend`. Rust's closed verdict enums previously rejected + these values. Source compatibility: Rust exhaustive `match` on the verdict + enums and `ClaimEvidence` struct literals, Go unkeyed `BanSafeClaimEvidence` + literals and the C# `BanSafeClaimEvidence` constructor/Deconstruct need the + added members. Pinned to unmerged API #845 source `5ccd3dcb`; package + publication waits for that API change to merge and deploy. - All six server SDKs expose optional automatic-pause `reason`/`code` and optional recipient-skip `messageIndex`/`followUpId` from accepted API source `9bb1e2ed`. Future reason/code strings and raw envelope fields are retained; diff --git a/contracts/README.md b/contracts/README.md index 928167f4..1480d176 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,5 +1,15 @@ # Contract coverage +## Ban Credit claim waivers + +`ban-credit-waivers.json` pins the `BanSafeClaim` and `BanSafeClaimPayload` +verdict enums and the `linkedDeviceInteractive` and `referralGroupSend` claim +evidence fields to API source `5ccd3dcb7016d87994bcf19c58f2cc148ac82428` +(polymorfa/polymorfa#845, unmerged). It is held with `published: false`. +`ban-credit-waivers-contract.test.ts` checks the TypeScript types against it; +each native package has a decoding test. No operation is added. Re-pin to the +merged API revision before publication. + ## Campaign and custody additions `campaign-custody-20261014.json` pins thirty machine-accessible operations and diff --git a/contracts/ban-credit-waivers.json b/contracts/ban-credit-waivers.json new file mode 100644 index 00000000..7fe48004 --- /dev/null +++ b/contracts/ban-credit-waivers.json @@ -0,0 +1,72 @@ +{ + "repository": "polymorfa/polymorfa", + "sourceCommit": "5ccd3dcb7016d87994bcf19c58f2cc148ac82428", + "published": false, + "scope": "Ban Credit claim waivers: verdicts linked_device_interactive and referral_group_send, and claim evidence linkedDeviceInteractive and referralGroupSend (polymorfa/polymorfa#845, unmerged). Other SDK contracts retain their recorded source revisions.", + "sources": { + "platform": { + "sourcePath": "apps/api/docs/openapi.management.json", + "sha256": "bc15edfa17c674f3aed5a192624bacaa96984155d2cc072ce1938d67f28fffa9" + }, + "messaging": { + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "b8a96e1f31e33ed5c4b1fb59fa9939f7f05456ed583a7ac9c68cbb10a7e29eb6" + } + }, + "verdicts": { + "BanSafeClaim": { + "family": "platform", + "enum": [ + "other_device", + "customer_conduct", + "shared_network", + "ours", + "inconclusive", + "simultaneous_calls", + "linked_device_interactive", + "referral_group_send" + ] + }, + "BanSafeClaimPayload": { + "family": "messaging", + "enum": [ + "other_device", + "customer_conduct", + "shared_network", + "ours", + "inconclusive", + "simultaneous_calls", + "linked_device_interactive", + "referral_group_send" + ] + } + }, + "evidence": { + "BanSafeClaimEvidence": { + "family": "platform", + "required": [ + "attributionRuleVersion", + "windowDays", + "deviceEvidence", + "otherDevices", + "restrictedInWindow", + "criticalFindingDays", + "sharedConnection", + "measuredHours", + "simultaneousCalls", + "linkedDeviceInteractive", + "referralGroupSend" + ], + "properties": { + "linkedDeviceInteractive": { + "type": "boolean", + "description": "Whether this number sent an interactive message (buttons, lists, product messages, Flows and similar) from a linked device inside the window. When true, Ban Credit does not cover the ban and the claim is denied when filed. False for claims filed before this was recorded." + }, + "referralGroupSend": { + "type": "boolean", + "description": "Whether this number posted a referral message to WhatsApp groups inside the window. When true, Ban Credit does not cover the ban and the claim is denied when filed. False for claims filed before this was recorded." + } + } + } + } +} diff --git a/packages/dotnet/README.md b/packages/dotnet/README.md index 16e16721..2bffd0bb 100644 --- a/packages/dotnet/README.md +++ b/packages/dotnet/README.md @@ -1010,6 +1010,13 @@ when the 30-day claim window overlaps an enabled period. Claim verdicts and `bansafe.claim` webhook verdicts recognize `simultaneous_calls`. Claim evidence includes `simultaneousCalls`; the webhook does not acquire an evidence object. +Ban Credit claims also recognize `linked_device_interactive` (the Number sent +an interactive message from a linked device in the 30 days before the ban) and +`referral_group_send` (it posted a referral message to WhatsApp groups in that +window), on claim reads and `bansafe.claim` webhooks. Both open `denied` with +amount 0. Claim evidence includes `linkedDeviceInteractive` and +`referralGroupSend`; claims filed before these were recorded report `false`. + No browser/client-token authority, call media behavior, automatic state read, retry policy or feature availability is added by these SDK fields. diff --git a/packages/dotnet/src/Polymorfa.Sdk/BanSafe.cs b/packages/dotnet/src/Polymorfa.Sdk/BanSafe.cs index 6fde3136..a9449d13 100644 --- a/packages/dotnet/src/Polymorfa.Sdk/BanSafe.cs +++ b/packages/dotnet/src/Polymorfa.Sdk/BanSafe.cs @@ -64,7 +64,7 @@ public sealed record BanSafeEnforcementSummary : BanSafeNumber public sealed record BanSafeIncident(string Id, string SessionId, string Session, string PhoneNumber, string ProjectId, string Kind, string Source, string Resolution, bool Ambiguous, string StartedAt, string? EndsAt, string? ClosedAt, string? ClosedBy, string? ClaimId, string? Note, string? ReportedBy, string CreatedAt); public sealed record ReportBanSafeIncidentRequest(string Session, string? OccurredAt = null, string? Note = null); public sealed record BanSafeIncidentReceipt(string IncidentId, bool Created, string SessionId, string Session, string OccurredAt); -public sealed record BanSafeClaimEvidence(long? AttributionRuleVersion, int WindowDays, bool DeviceEvidence, long OtherDevices, bool RestrictedInWindow, long CriticalFindingDays, bool SharedConnection, decimal MeasuredHours, bool SimultaneousCalls); +public sealed record BanSafeClaimEvidence(long? AttributionRuleVersion, int WindowDays, bool DeviceEvidence, long OtherDevices, bool RestrictedInWindow, long CriticalFindingDays, bool SharedConnection, decimal MeasuredHours, bool SimultaneousCalls, bool LinkedDeviceInteractive, bool ReferralGroupSend); public sealed record BanSafeClaim(string Id, string IncidentId, string SessionId, string Session, string PhoneNumber, string ProjectId, string Status, string Verdict, string WindowStart, string WindowEnd, decimal MeasuredCents, decimal CapCents, decimal AmountCents, BanSafeClaimEvidence Evidence, string Summary, string Reason, string? DecidedAt, string? PaidAt, string CreatedAt); public sealed record BanSafeSignalDefinition(string Key, string Label, string Group, string Kind, string Unit, string Description); public sealed record BanSafeCodeCount(long Code, long Count); diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/BanSafeTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/BanSafeTests.cs index f8de34e9..65ccbc01 100644 --- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/BanSafeTests.cs +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/BanSafeTests.cs @@ -5,7 +5,7 @@ internal static class BanSafeTests { private const string Health = """{"sessionId":"s1","session":"support","phoneNumber":"+15550001111","projectId":"p1","health":null,"band":"unknown","healthSource":"unavailable","healthEstimatorVersion":null,"healthModelVersion":null,"healthEvaluatedAt":null,"healthFeatureCoverage":null,"healthReliability":"unavailable","healthUnavailableReason":"no_active_model","healthProbabilities":null,"mostLikelyHealthState":null,"healthExplanation":null,"observedAccountState":{"state":"limited","observedAt":"2026-09-22","source":"restriction_event"},"enforcement":null}"""; private const string Incident = """{"id":"i1","sessionId":"s1","session":"support","phoneNumber":"+15550001111","projectId":"p1","kind":"customer_report","source":"customer","resolution":"retracted","ambiguous":true,"startedAt":"2026-09-22","endsAt":null,"closedAt":null,"closedBy":"owner","claimId":null,"note":null,"reportedBy":null,"createdAt":"2026-09-22"}"""; - private const string Claim = """{"id":"c1","incidentId":"i1","sessionId":"s1","session":"support","phoneNumber":"+15550001111","projectId":"p1","status":"paid","verdict":"ours","windowStart":"2026-09-01","windowEnd":"2026-09-22","measuredCents":1.123456,"capCents":2.123456,"amountCents":0.123456,"evidence":{"attributionRuleVersion":1,"windowDays":21,"deviceEvidence":false,"otherDevices":0,"restrictedInWindow":false,"criticalFindingDays":0,"sharedConnection":false,"simultaneousCalls":false,"measuredHours":1.5},"summary":"Reviewed","reason":"ours","decidedAt":null,"paidAt":null,"createdAt":"2026-09-22"}"""; + private const string Claim = """{"id":"c1","incidentId":"i1","sessionId":"s1","session":"support","phoneNumber":"+15550001111","projectId":"p1","status":"paid","verdict":"ours","windowStart":"2026-09-01","windowEnd":"2026-09-22","measuredCents":1.123456,"capCents":2.123456,"amountCents":0.123456,"evidence":{"attributionRuleVersion":1,"windowDays":21,"deviceEvidence":false,"otherDevices":0,"restrictedInWindow":false,"criticalFindingDays":0,"sharedConnection":false,"simultaneousCalls":false,"linkedDeviceInteractive":false,"referralGroupSend":false,"measuredHours":1.5},"summary":"Reviewed","reason":"ours","decidedAt":null,"paidAt":null,"createdAt":"2026-09-22"}"""; private const string Collection = """{"state":"fresh","latestFlushedAt":"2026-09-22","latestReceivedAt":"2026-09-22","freshUntil":null,"recordVersion":1,"collectorVersion":1,"partial":false,"droppedRecords":0}"""; private const string Snapshot = """{"bucketStart":"2026-09-22","flushedAt":"2026-09-22","receivedAt":"2026-09-22","partial":false,"recordVersion":1,"signals":[{"key":"latency","label":"Latency","group":"delivery","kind":"histogram","unit":"milliseconds","description":"Latency","measured":true,"value":[1.25,2.5],"sampleSize":2,"codes":null},{"key":"ready","label":"Ready","group":"connection","kind":"boolean","unit":"none","description":"Ready","measured":true,"value":false,"sampleSize":0,"codes":[{"code":403,"count":1}]}]}"""; public static async Task RunAsync() @@ -26,6 +26,20 @@ public static async Task RunAsync() await Organization("POST", "/platform/bansafe/incidents/i1/retract", null, Envelope(Incident), async c => ResourceTests.Equal((await c.BanSafe.RetractIncidentAsync("i1")).Data.Data.ClosedBy, "owner")); await Organization("GET", "/platform/bansafe/claims?session=support&status=paid", null, Page(Claim), async c => ResourceTests.Equal((await c.BanSafe.ListClaimsAsync(new(Session: "support", Status: "paid"))).Items[0].AmountCents, 0.123456m)); await Organization("GET", "/platform/bansafe/claims/c1", null, Envelope(Claim), async c => ResourceTests.Equal((await c.BanSafe.GetClaimAsync("c1")).Data.Data.Evidence.MeasuredHours, 1.5m)); + // Ban Credit waivers: both verdicts and evidence flags decode from the claim read. + foreach (var (verdict, linked, referral) in new[] { ("linked_device_interactive", true, false), ("referral_group_send", false, true) }) + { + var waived = Claim.Replace("\"status\":\"paid\",\"verdict\":\"ours\"", $"\"status\":\"denied\",\"verdict\":\"{verdict}\"").Replace("\"attributionRuleVersion\":1", "\"attributionRuleVersion\":3").Replace("\"linkedDeviceInteractive\":false,\"referralGroupSend\":false", $"\"linkedDeviceInteractive\":{(linked ? "true" : "false")},\"referralGroupSend\":{(referral ? "true" : "false")}"); + await Organization("GET", "/platform/bansafe/claims/c1", null, Envelope(waived), async c => + { + var claim = (await c.BanSafe.GetClaimAsync("c1")).Data.Data; + ResourceTests.Equal(claim.Verdict, verdict); + ResourceTests.Equal(claim.Evidence.AttributionRuleVersion, 3L); + ResourceTests.True(claim.Evidence.LinkedDeviceInteractive == linked && claim.Evidence.ReferralGroupSend == referral); + }); + var payload = JsonSerializer.Deserialize($$"""{"id":"c1","incidentId":"i1","phoneNumber":"+15550001111","status":"denied","verdict":"{{verdict}}","windowStart":"a","windowEnd":"b","measuredCents":0,"capCents":0,"amountCents":0,"summary":"s","reason":"r","decidedAt":null,"paidAt":null}""", new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + ResourceTests.Equal(payload.Verdict, verdict); + } var options = new JsonSerializerOptions(); foreach (var json in new[] { "null", "0", "false", "\"unknown\"", "[]", "[1.5,2.25]" }) { var v = JsonSerializer.Deserialize(json, options); ResourceTests.Equal(JsonSerializer.Serialize(v, options), json); } foreach (var json in new[] { "{}", "[true]" }) { try { JsonSerializer.Deserialize(json, options); throw new Exception("Invalid signal value accepted"); } catch (JsonException) { } catch (InvalidOperationException) { } } Console.WriteLine("PASS 15 BanSafe routes, health authority/nulls, closed signal variants and six-decimal claims"); diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs index bc74783c..9cbebb2f 100644 --- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs @@ -37,7 +37,7 @@ public static async Task RunAsync() await Invalid(() => client.Calls.UpdateCallSettingsAsync("s", new(AcknowledgeRisk: true))); await Invalid(() => client.Calls.ReportAsync("call", new("quality", "connection1", new("polymorfa-dotnet", "0.1.0", "other"), Quality: new(RttMs: -1)))); var json = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; - var claim = System.Text.Json.JsonSerializer.Deserialize("""{"attributionRuleVersion":null,"windowDays":30,"deviceEvidence":false,"otherDevices":0,"restrictedInWindow":false,"criticalFindingDays":0,"sharedConnection":false,"simultaneousCalls":true,"measuredHours":0}""", json)!; + var claim = System.Text.Json.JsonSerializer.Deserialize("""{"attributionRuleVersion":null,"windowDays":30,"deviceEvidence":false,"otherDevices":0,"restrictedInWindow":false,"criticalFindingDays":0,"sharedConnection":false,"simultaneousCalls":true,"linkedDeviceInteractive":false,"referralGroupSend":false,"measuredHours":0}""", json)!; True(claim.SimultaneousCalls); Console.WriteLine("PASS Calls routes, simultaneous-call presence, evidence and validation"); } diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs index 90bf3ee3..3633209e 100644 --- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs @@ -7,6 +7,8 @@ if (args.Contains("--simultaneous-calls-only")) { await CallsTests.RunAsync(); return; } +if (args.Contains("--bansafe-only")) { await BanSafeTests.RunAsync(); Console.WriteLine("PASS BanSafe routes and Ban Credit waiver claims"); return; } + if (args.Contains("--official-round2-only")) { await OfficialRound2Tests.RunAsync(); return; } OpaqueCredentialTests.Run(); diff --git a/packages/go/README.md b/packages/go/README.md index f54a772a..8ff2e1a8 100644 --- a/packages/go/README.md +++ b/packages/go/README.md @@ -805,6 +805,13 @@ when the 30-day claim window overlaps an enabled period. Claim verdicts and `bansafe.claim` webhook verdicts recognize `simultaneous_calls`. Claim evidence includes `simultaneousCalls`; the webhook does not acquire an evidence object. +Ban Credit claims also recognize `linked_device_interactive` (the Number sent +an interactive message from a linked device in the 30 days before the ban) and +`referral_group_send` (it posted a referral message to WhatsApp groups in that +window), on claim reads and `bansafe.claim` webhooks. Both open `denied` with +amount 0. Claim evidence includes `linkedDeviceInteractive` and +`referralGroupSend`; claims filed before these were recorded report `false`. + No browser/client-token authority, call media behavior, automatic state read, retry policy or feature availability is added by these SDK fields. diff --git a/packages/go/ban_credit_waivers_test.go b/packages/go/ban_credit_waivers_test.go new file mode 100644 index 00000000..7b50b47f --- /dev/null +++ b/packages/go/ban_credit_waivers_test.go @@ -0,0 +1,41 @@ +package polymorfa + +import ( + "encoding/json" + "testing" +) + +func TestBanCreditWaiverVerdictsAndEvidence(t *testing.T) { + for _, tc := range []struct { + verdict string + evidence string + linked bool + referral bool + }{ + {"linked_device_interactive", `{"attributionRuleVersion":3,"linkedDeviceInteractive":true,"referralGroupSend":false}`, true, false}, + {"referral_group_send", `{"attributionRuleVersion":3,"linkedDeviceInteractive":false,"referralGroupSend":true}`, false, true}, + } { + var claim BanSafeClaim + if err := json.Unmarshal([]byte(`{"status":"denied","amountCents":0,"verdict":"`+tc.verdict+`","evidence":`+tc.evidence+`}`), &claim); err != nil { + t.Fatal(err) + } + if claim.Verdict != tc.verdict || claim.Evidence.AttributionRuleVersion == nil || *claim.Evidence.AttributionRuleVersion != 3 { + t.Fatalf("decoded %+v", claim) + } + if claim.Evidence.LinkedDeviceInteractive != tc.linked || claim.Evidence.ReferralGroupSend != tc.referral { + t.Fatalf("evidence %+v", claim.Evidence) + } + encoded, err := json.Marshal(claim.Evidence) + if err != nil { + t.Fatal(err) + } + var round map[string]any + if err := json.Unmarshal(encoded, &round); err != nil || round["linkedDeviceInteractive"] != tc.linked || round["referralGroupSend"] != tc.referral { + t.Fatalf("round trip %s", encoded) + } + var payload BanSafeClaimPayload + if err := json.Unmarshal([]byte(`{"verdict":"`+tc.verdict+`"}`), &payload); err != nil || payload.Verdict != tc.verdict { + t.Fatalf("webhook payload %+v %v", payload, err) + } + } +} diff --git a/packages/go/bansafe.go b/packages/go/bansafe.go index c422e842..31a9bfe9 100644 --- a/packages/go/bansafe.go +++ b/packages/go/bansafe.go @@ -182,15 +182,17 @@ type BanSafeIncidentReceipt struct { OccurredAt string `json:"occurredAt"` } type BanSafeClaimEvidence struct { - SimultaneousCalls bool `json:"simultaneousCalls"` - AttributionRuleVersion *int `json:"attributionRuleVersion"` - WindowDays int `json:"windowDays"` - DeviceEvidence bool `json:"deviceEvidence"` - OtherDevices int `json:"otherDevices"` - RestrictedInWindow bool `json:"restrictedInWindow"` - CriticalFindingDays int `json:"criticalFindingDays"` - SharedConnection bool `json:"sharedConnection"` - MeasuredHours int `json:"measuredHours"` + SimultaneousCalls bool `json:"simultaneousCalls"` + LinkedDeviceInteractive bool `json:"linkedDeviceInteractive"` + ReferralGroupSend bool `json:"referralGroupSend"` + AttributionRuleVersion *int `json:"attributionRuleVersion"` + WindowDays int `json:"windowDays"` + DeviceEvidence bool `json:"deviceEvidence"` + OtherDevices int `json:"otherDevices"` + RestrictedInWindow bool `json:"restrictedInWindow"` + CriticalFindingDays int `json:"criticalFindingDays"` + SharedConnection bool `json:"sharedConnection"` + MeasuredHours int `json:"measuredHours"` } type BanSafeClaim struct { ID string `json:"id"` diff --git a/packages/php/README.md b/packages/php/README.md index 27a06003..b9112f98 100644 --- a/packages/php/README.md +++ b/packages/php/README.md @@ -960,6 +960,13 @@ when the 30-day claim window overlaps an enabled period. Claim verdicts and `bansafe.claim` webhook verdicts recognize `simultaneous_calls`. Claim evidence includes `simultaneousCalls`; the webhook does not acquire an evidence object. +Ban Credit claims also recognize `linked_device_interactive` (the Number sent +an interactive message from a linked device in the 30 days before the ban) and +`referral_group_send` (it posted a referral message to WhatsApp groups in that +window), on claim reads and `bansafe.claim` webhooks. Both open `denied` with +amount 0. Claim evidence includes `linkedDeviceInteractive` and +`referralGroupSend`; claims filed before these were recorded report `false`. + No browser/client-token authority, call media behavior, automatic state read, retry policy or feature availability is added by these SDK fields. diff --git a/packages/php/src/BanSafeTelemetryModels.php b/packages/php/src/BanSafeTelemetryModels.php index 8f2007c1..3b182380 100644 --- a/packages/php/src/BanSafeTelemetryModels.php +++ b/packages/php/src/BanSafeTelemetryModels.php @@ -25,7 +25,7 @@ * @phpstan-type EnforcementSummary array{health:int|float|null,band:Band,healthSource:Source,healthEstimatorVersion:?string,healthModelVersion:?string,healthEvaluatedAt:?string,healthFeatureCoverage:int|float|null,healthReliability:Reliability,healthUnavailableReason:Unavailable|null,healthProbabilities:Probabilities|null,mostLikelyHealthState:HealthState|null,healthExplanation:Explanation|null,observedAccountState:Observed|null,sessionId:string,session:string,phoneNumber:string,projectId:string,rung:Rung,previousRung:Rung,organizationFloor:Rung,reason:string,source:'automatic'|'operator',throughputPerMinute:int|float|null,blocksUnsolicited:bool,suspended:bool,enforcement?:Enforcement|null,blockingFindings:list,startedAt:string,eligibleLiftAt:?string,liftRequires:string,operatorHold:bool,appealState:Appeal,state:'applied'|'applying'} * @phpstan-type Incident array{id:string,sessionId:string,session:string,phoneNumber:string,projectId:string,kind:'cap_warning'|'cap_reached'|'timelock'|'temporary_ban'|'permanent_ban'|'connect_blocked'|'customer_report',source:'runtime'|'customer',resolution:string,ambiguous:bool,startedAt:string,endsAt:?string,closedAt:?string,closedBy:?string,claimId:?string,note:?string,reportedBy:?string,createdAt:string} * @phpstan-type ClaimStatus 'filed'|'under_review'|'approved'|'denied'|'paid'|'reversed' - * @phpstan-type Claim array{id:string,incidentId:string,sessionId:string,session:string,phoneNumber:string,projectId:string,status:ClaimStatus,verdict:'other_device'|'customer_conduct'|'shared_network'|'simultaneous_calls'|'ours'|'inconclusive',windowStart:string,windowEnd:string,measuredCents:int|float,capCents:int|float,amountCents:int|float,evidence:array{attributionRuleVersion:?int,windowDays:int,deviceEvidence:bool,otherDevices:int,restrictedInWindow:bool,criticalFindingDays:int,sharedConnection:bool,simultaneousCalls:bool,measuredHours:int|float},summary:string,reason:string,decidedAt:?string,paidAt:?string,createdAt:string} + * @phpstan-type Claim array{id:string,incidentId:string,sessionId:string,session:string,phoneNumber:string,projectId:string,status:ClaimStatus,verdict:'other_device'|'customer_conduct'|'shared_network'|'simultaneous_calls'|'linked_device_interactive'|'referral_group_send'|'ours'|'inconclusive',windowStart:string,windowEnd:string,measuredCents:int|float,capCents:int|float,amountCents:int|float,evidence:array{attributionRuleVersion:?int,windowDays:int,deviceEvidence:bool,otherDevices:int,restrictedInWindow:bool,criticalFindingDays:int,sharedConnection:bool,simultaneousCalls:bool,linkedDeviceInteractive:bool,referralGroupSend:bool,measuredHours:int|float},summary:string,reason:string,decidedAt:?string,paidAt:?string,createdAt:string} * @phpstan-type SignalDefinition array{key:string,label:string,group:string,kind:'number'|'boolean'|'enum'|'histogram'|'code_counts',unit:'count'|'milliseconds'|'unix_milliseconds'|'ratio'|'none',description:string} * @phpstan-type Signal array{key:string,label:string,group:string,kind:'number'|'boolean'|'enum'|'histogram'|'code_counts',unit:'count'|'milliseconds'|'unix_milliseconds'|'ratio'|'none',description:string,measured:bool,value:int|float|bool|string|list|null,sampleSize:?int,codes:list|null} * @phpstan-type CollectionStatus array{state:'fresh'|'stale'|'not_collected'|'unsupported',latestFlushedAt:?string,latestReceivedAt:?string,freshUntil:?string,recordVersion:?int,collectorVersion:?int,partial:?bool,droppedRecords:?int} diff --git a/packages/php/src/WebhookModels.php b/packages/php/src/WebhookModels.php index 571833bc..e91e9829 100644 --- a/packages/php/src/WebhookModels.php +++ b/packages/php/src/WebhookModels.php @@ -76,7 +76,7 @@ * @phpstan-type HealthThreshold array{sessionId:string,projectId:string,health:int|float,threshold:int|float,healthSource:'rules_v1'|'ml_model',estimatorVersion:string,modelVersion:?string,evaluatedAt:string,policyVersion:int|float,episodeId:string,actionId:string} * @phpstan-type BanSafeAction array{phoneNumber:string,action:'applied'|'changed'|'lifted'|'daily_allowance_reached',scope:'number'|'organization',rung:Rung,previousRung:Rung|null,reason:'health'|'finding'|'org_pattern'|'repeat'|'restriction'|'operator'|'health_model_cutover'|'warmup',health:int|float|null,healthBand:'good'|'fair'|'poor'|'failing'|'unknown',requires:list,throughputPerMinute:int|float|null,eligibleLiftAt:?string,liftRequires:string,appealUrl:string,startedAt:string,docs:string} * @phpstan-type BanSafeIncident array{id:string,phoneNumber:string,kind:'cap_warning'|'cap_reached'|'timelock'|'temporary_ban'|'permanent_ban'|'connect_blocked'|'customer_report',source:'runtime'|'customer',startedAt:string,endsAt:?string,belief:int|float,resolution:'open'|'corroborated'|'contradicted'|'phone_switch'|'final',claimId:?string,closedAt:?string} - * @phpstan-type BanSafeClaim array{id:string,incidentId:string,phoneNumber:string,status:'filed'|'under_review'|'approved'|'denied'|'paid'|'reversed',verdict:'other_device'|'customer_conduct'|'shared_network'|'simultaneous_calls'|'ours'|'inconclusive',windowStart:string,windowEnd:string,measuredCents:int|float,capCents:int|float,amountCents:int|float,summary:string,reason:string,decidedAt:?string,paidAt:?string} + * @phpstan-type BanSafeClaim array{id:string,incidentId:string,phoneNumber:string,status:'filed'|'under_review'|'approved'|'denied'|'paid'|'reversed',verdict:'other_device'|'customer_conduct'|'shared_network'|'simultaneous_calls'|'linked_device_interactive'|'referral_group_send'|'ours'|'inconclusive',windowStart:string,windowEnd:string,measuredCents:int|float,capCents:int|float,amountCents:int|float,summary:string,reason:string,decidedAt:?string,paidAt:?string} * @phpstan-type PaymentStatus array{reportedBy:'whatsapp',providerEventId:string,referenceId:string,conversation:array{id?:string,phoneNumber?:string,bsuid?:string,parentBsuid?:string},kind:'payment_status',status:string,amount?:array{value:int|float,offset:int|float},currency?:string,transaction?:array{id?:string,providerTransactionId?:string,provider?:string,status?:string,method?:string,errorCode?:string}} * @phpstan-type PaymentMethod array{reportedBy:'whatsapp',providerEventId:string,referenceId:string,conversation:array{id?:string,phoneNumber?:string,bsuid?:string,parentBsuid?:string},kind:'payment_method_selected',messageId:string,paymentMethod:string,lastFourDigits?:string,credentialId?:string,paymentTimestamp?:int|float} * @phpstan-type RuntimeTemplate array{templateName:string,templateId:string,status:string,category:string,reason:string,qualityRating:string} diff --git a/packages/php/tests/platform_bansafe.php b/packages/php/tests/platform_bansafe.php index c63101f2..8d966a56 100644 --- a/packages/php/tests/platform_bansafe.php +++ b/packages/php/tests/platform_bansafe.php @@ -13,6 +13,7 @@ $enforcement = $health + ['sessionId' => 'sid','session' => 'session','phoneNumber' => '+15555555555','projectId' => 'project','rung' => 'notify','previousRung' => 'none','organizationFloor' => 'none','reason' => 'Fixture','source' => 'operator','throughputPerMinute' => null,'blocksUnsolicited' => false,'suspended' => false,'blockingFindings' => [],'startedAt' => 'now','eligibleLiftAt' => null,'liftRequires' => 'Review','operatorHold' => true,'appealState' => 'requested','state' => 'applying']; $incident = ['id' => 'incident','sessionId' => 'sid','session' => 'session','phoneNumber' => '+15555555555','projectId' => 'project','kind' => 'customer_report','source' => 'customer','resolution' => 'unresolved','ambiguous' => true,'startedAt' => 'now','endsAt' => null,'closedAt' => null,'closedBy' => null,'claimId' => null,'note' => 'Fixture','reportedBy' => 'customer','createdAt' => 'now']; $claim = ['id' => 'claim','incidentId' => 'incident','sessionId' => 'sid','session' => 'session','phoneNumber' => '+15555555555','projectId' => 'project','status' => 'under_review','verdict' => 'inconclusive','windowStart' => 'now','windowEnd' => 'now','measuredCents' => 0.000001,'capCents' => 12.345678,'amountCents' => 0.125,'evidence' => ['attributionRuleVersion' => 1,'windowDays' => 7,'deviceEvidence' => false,'otherDevices' => 0,'restrictedInWindow' => true,'criticalFindingDays' => 2,'sharedConnection' => false,'measuredHours' => 1.5],'summary' => 'Fixture','reason' => 'Evidence pending','decidedAt' => null,'paidAt' => null,'createdAt' => 'now']; +$waived = array_replace($claim, ['status' => 'denied','verdict' => 'referral_group_send','amountCents' => 0,'evidence' => ['attributionRuleVersion' => 3,'windowDays' => 30,'deviceEvidence' => false,'otherDevices' => 0,'restrictedInWindow' => false,'criticalFindingDays' => 0,'sharedConnection' => false,'simultaneousCalls' => false,'linkedDeviceInteractive' => true,'referralGroupSend' => true,'measuredHours' => 0]]); $page = fn ($data) => ['data' => [$data],'page' => ['nextCursor' => null,'hasMore' => false]]; $envelope = fn ($data) => ['data' => $data]; $created = ['incidentId' => 'incident','created' => true,'sessionId' => 'sid','session' => 'session','occurredAt' => 'now']; @@ -32,6 +33,7 @@ ['POST','/platform/bansafe/incidents/incident/retract',null,$envelope($incident),fn ($c) => $c->banSafe->retractIncident('incident')], ['GET','/platform/bansafe/claims?status=under_review',null,$page($claim),fn ($c) => $c->banSafe->listClaims(['status' => 'under_review'])], ['GET','/platform/bansafe/claims/claim',null,$envelope($claim),fn ($c) => $c->banSafe->getClaim('claim')], + ['GET','/platform/bansafe/claims/waived',null,$envelope($waived),fn ($c) => $c->banSafe->getClaim('waived')], ], fn ($credential, $url) => new Polymorfa\Client($credential, baseUrl:$url)); check($number['health'] === null && $number['band'] === 'unknown', 'Unavailable health remains distinct from a healthy score'); check($claim['measuredCents'] === 0.000001 && $claim['amountCents'] === 0.125, 'BanSafe fractional credits retained'); diff --git a/packages/python/README.md b/packages/python/README.md index d640b700..d304503a 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -827,6 +827,13 @@ when the 30-day claim window overlaps an enabled period. Claim verdicts and `bansafe.claim` webhook verdicts recognize `simultaneous_calls`. Claim evidence includes `simultaneousCalls`; the webhook does not acquire an evidence object. +Ban Credit claims also recognize `linked_device_interactive` (the Number sent +an interactive message from a linked device in the 30 days before the ban) and +`referral_group_send` (it posted a referral message to WhatsApp groups in that +window), on claim reads and `bansafe.claim` webhooks. Both open `denied` with +amount 0. Claim evidence includes `linkedDeviceInteractive` and +`referralGroupSend`; claims filed before these were recorded report `false`. + No browser/client-token authority, call media behavior, automatic state read, retry policy or feature availability is added by these SDK fields. diff --git a/packages/python/src/polymorfa/bansafe_observations.py b/packages/python/src/polymorfa/bansafe_observations.py index ae086b7c..c3dc0c1f 100644 --- a/packages/python/src/polymorfa/bansafe_observations.py +++ b/packages/python/src/polymorfa/bansafe_observations.py @@ -37,6 +37,8 @@ "customer_conduct", "shared_network", "simultaneous_calls", + "linked_device_interactive", + "referral_group_send", "ours", "inconclusive", ] @@ -252,6 +254,8 @@ class IncidentReceipt(TypedDict): class ClaimEvidence(TypedDict): simultaneousCalls: bool + linkedDeviceInteractive: bool + referralGroupSend: bool attributionRuleVersion: int | None windowDays: int deviceEvidence: bool diff --git a/packages/python/src/polymorfa/webhook_models.py b/packages/python/src/polymorfa/webhook_models.py index 6f35d55d..de32d61c 100644 --- a/packages/python/src/polymorfa/webhook_models.py +++ b/packages/python/src/polymorfa/webhook_models.py @@ -748,6 +748,8 @@ class BanSafeClaimPayload(TypedDict): "customer_conduct", "shared_network", "simultaneous_calls", + "linked_device_interactive", + "referral_group_send", "ours", "inconclusive", ] diff --git a/packages/python/tests/test_ban_credit_waivers.py b/packages/python/tests/test_ban_credit_waivers.py new file mode 100644 index 00000000..3b52c78b --- /dev/null +++ b/packages/python/tests/test_ban_credit_waivers.py @@ -0,0 +1,20 @@ +from typing import get_args, get_type_hints + +from polymorfa.bansafe_observations import Claim, ClaimEvidence, ClaimVerdict +from polymorfa.webhook_models import BanSafeClaimPayload + +WAIVERS = ("linked_device_interactive", "referral_group_send") + + +def test_claim_verdicts_include_the_ban_credit_waivers() -> None: + for verdict in WAIVERS: + assert verdict in get_args(ClaimVerdict) + assert verdict in get_args(get_type_hints(BanSafeClaimPayload)["verdict"]) + assert get_type_hints(Claim)["verdict"] == ClaimVerdict + + +def test_claim_evidence_types_the_waiver_flags() -> None: + hints = get_type_hints(ClaimEvidence) + assert hints["linkedDeviceInteractive"] is bool + assert hints["referralGroupSend"] is bool + assert {"linkedDeviceInteractive", "referralGroupSend"} <= ClaimEvidence.__required_keys__ diff --git a/packages/python/tests/test_bansafe_observations.py b/packages/python/tests/test_bansafe_observations.py index 96cd332c..85747562 100644 --- a/packages/python/tests/test_bansafe_observations.py +++ b/packages/python/tests/test_bansafe_observations.py @@ -157,6 +157,8 @@ "criticalFindingDays": 1, "sharedConnection": False, "simultaneousCalls": False, + "linkedDeviceInteractive": False, + "referralGroupSend": False, "measuredHours": 100, }, "summary": "Review", diff --git a/packages/rust/README.md b/packages/rust/README.md index 00f3db91..9eca8194 100644 --- a/packages/rust/README.md +++ b/packages/rust/README.md @@ -894,6 +894,13 @@ when the 30-day claim window overlaps an enabled period. Claim verdicts and `bansafe.claim` webhook verdicts recognize `simultaneous_calls`. Claim evidence includes `simultaneousCalls`; the webhook does not acquire an evidence object. +Ban Credit claims also recognize `linked_device_interactive` (the Number sent +an interactive message from a linked device in the 30 days before the ban) and +`referral_group_send` (it posted a referral message to WhatsApp groups in that +window), on claim reads and `bansafe.claim` webhooks. Both open `denied` with +amount 0. Claim evidence includes `linkedDeviceInteractive` and +`referralGroupSend`; claims filed before these were recorded report `false`. + No browser/client-token authority, call media behavior, automatic state read, retry policy or feature availability is added by these SDK fields. diff --git a/packages/rust/src/bansafe.rs b/packages/rust/src/bansafe.rs index 8b809746..0a2ef3b0 100644 --- a/packages/rust/src/bansafe.rs +++ b/packages/rust/src/bansafe.rs @@ -28,7 +28,7 @@ enumeration!(ResolveReason {Clean=>"clean",KeyRetired=>"key_retired",NumberRemov enumeration!(IncidentKind {CapWarning=>"cap_warning",CapReached=>"cap_reached",Timelock=>"timelock",TemporaryBan=>"temporary_ban",PermanentBan=>"permanent_ban",ConnectBlocked=>"connect_blocked",CustomerReport=>"customer_report"}); enumeration!(IncidentSource {Runtime=>"runtime",Customer=>"customer"}); enumeration!(ClaimStatus {Filed=>"filed",UnderReview=>"under_review",Approved=>"approved",Denied=>"denied",Paid=>"paid",Reversed=>"reversed"}); -enumeration!(ClaimVerdict {OtherDevice=>"other_device",CustomerConduct=>"customer_conduct",SharedNetwork=>"shared_network",SimultaneousCalls=>"simultaneous_calls",Ours=>"ours",Inconclusive=>"inconclusive"}); +enumeration!(ClaimVerdict {OtherDevice=>"other_device",CustomerConduct=>"customer_conduct",SharedNetwork=>"shared_network",SimultaneousCalls=>"simultaneous_calls",LinkedDeviceInteractive=>"linked_device_interactive",ReferralGroupSend=>"referral_group_send",Ours=>"ours",Inconclusive=>"inconclusive"}); enumeration!(SignalKind {Number=>"number",Boolean=>"boolean",Enum=>"enum",Histogram=>"histogram",CodeCounts=>"code_counts"}); enumeration!(SignalUnit {Count=>"count",Milliseconds=>"milliseconds",UnixMilliseconds=>"unix_milliseconds",Ratio=>"ratio",None=>"none"}); enumeration!(CollectionState {Fresh=>"fresh",Stale=>"stale",NotCollected=>"not_collected",Unsupported=>"unsupported"}); @@ -134,7 +134,7 @@ model!(IncidentReceipt { session: String, occurred_at: String }); -model!(ClaimEvidence {attribution_rule_version:Option,window_days:u32,device_evidence:bool,other_devices:u32,restricted_in_window:bool,critical_finding_days:u32,shared_connection:bool,simultaneous_calls:bool,measured_hours:u32}); +model!(ClaimEvidence {attribution_rule_version:Option,window_days:u32,device_evidence:bool,other_devices:u32,restricted_in_window:bool,critical_finding_days:u32,shared_connection:bool,simultaneous_calls:bool,linked_device_interactive:bool,referral_group_send:bool,measured_hours:u32}); model!(Claim {id:String,incident_id:String,session_id:String,session:String,phone_number:String,project_id:String,status:ClaimStatus,verdict:ClaimVerdict,window_start:String,window_end:String,measured_cents:f64,cap_cents:f64,amount_cents:f64,evidence:ClaimEvidence,summary:String,reason:String,decided_at:Option,paid_at:Option,created_at:String}); model!(SignalDefinition { key: String, diff --git a/packages/rust/src/webhook_payloads.rs b/packages/rust/src/webhook_payloads.rs index c6bf9a8f..e24cf58c 100644 --- a/packages/rust/src/webhook_payloads.rs +++ b/packages/rust/src/webhook_payloads.rs @@ -1136,6 +1136,8 @@ pub enum BanSafeClaimStatus { #[serde(rename_all = "snake_case")] pub enum BanSafeClaimVerdict { SimultaneousCalls, + LinkedDeviceInteractive, + ReferralGroupSend, OtherDevice, CustomerConduct, SharedNetwork, diff --git a/packages/rust/tests/ban_credit_waivers.rs b/packages/rust/tests/ban_credit_waivers.rs new file mode 100644 index 00000000..47674112 --- /dev/null +++ b/packages/rust/tests/ban_credit_waivers.rs @@ -0,0 +1,30 @@ +use serde_json::json; + +#[test] +fn ban_credit_waiver_verdicts_and_evidence_round_trip() { + for (value, verdict) in [ + ( + "linked_device_interactive", + polymorfa_sdk::bansafe::ClaimVerdict::LinkedDeviceInteractive, + ), + ( + "referral_group_send", + polymorfa_sdk::bansafe::ClaimVerdict::ReferralGroupSend, + ), + ] { + let decoded: polymorfa_sdk::bansafe::ClaimVerdict = + serde_json::from_value(json!(value)).unwrap(); + assert_eq!(decoded, verdict); + assert_eq!(serde_json::to_value(decoded).unwrap(), json!(value)); + let webhook: polymorfa_sdk::webhook_payloads::BanSafeClaimVerdict = + serde_json::from_value(json!(value)).unwrap(); + assert_eq!(serde_json::to_value(webhook).unwrap(), json!(value)); + } + let evidence: polymorfa_sdk::bansafe::ClaimEvidence = serde_json::from_value(json!({"attributionRuleVersion":3,"windowDays":30,"deviceEvidence":false,"otherDevices":0,"restrictedInWindow":false,"criticalFindingDays":0,"sharedConnection":false,"simultaneousCalls":false,"linkedDeviceInteractive":true,"referralGroupSend":true,"measuredHours":0})).unwrap(); + assert_eq!(evidence.attribution_rule_version, Some(3)); + assert!(evidence.linked_device_interactive); + assert!(evidence.referral_group_send); + let encoded = serde_json::to_value(&evidence).unwrap(); + assert_eq!(encoded["linkedDeviceInteractive"], json!(true)); + assert_eq!(encoded["referralGroupSend"], json!(true)); +} diff --git a/packages/rust/tests/bansafe.rs b/packages/rust/tests/bansafe.rs index e390570a..b21e0194 100644 --- a/packages/rust/tests/bansafe.rs +++ b/packages/rust/tests/bansafe.rs @@ -20,7 +20,7 @@ fn incident() -> Value { json!({"id":"incident","sessionId":"sid","session":"support","phoneNumber":"+1555","projectId":"project","kind":"customer_report","source":"customer","resolution":"open","ambiguous":true,"startedAt":"now","endsAt":null,"closedAt":null,"closedBy":null,"claimId":"claim","note":"reported","reportedBy":"member","createdAt":"now"}) } fn claim() -> Value { - json!({"id":"claim","incidentId":"incident","sessionId":"sid","session":"support","phoneNumber":"+1555","projectId":"project","status":"under_review","verdict":"inconclusive","windowStart":"then","windowEnd":"now","measuredCents":2.123456,"capCents":3.0,"amountCents":0.0,"evidence":{"attributionRuleVersion":1,"windowDays":7,"deviceEvidence":true,"otherDevices":2,"restrictedInWindow":true,"criticalFindingDays":1,"sharedConnection":false,"simultaneousCalls":false,"measuredHours":100},"summary":"Review","reason":"evidence","decidedAt":null,"paidAt":null,"createdAt":"now"}) + json!({"id":"claim","incidentId":"incident","sessionId":"sid","session":"support","phoneNumber":"+1555","projectId":"project","status":"under_review","verdict":"inconclusive","windowStart":"then","windowEnd":"now","measuredCents":2.123456,"capCents":3.0,"amountCents":0.0,"evidence":{"attributionRuleVersion":1,"windowDays":7,"deviceEvidence":true,"otherDevices":2,"restrictedInWindow":true,"criticalFindingDays":1,"sharedConnection":false,"simultaneousCalls":false,"linkedDeviceInteractive":false,"referralGroupSend":false,"measuredHours":100},"summary":"Review","reason":"evidence","decidedAt":null,"paidAt":null,"createdAt":"now"}) } fn signal() -> Value { json!({"key":"delivery","label":"Delivery","group":"delivery","kind":"code_counts","unit":"count","description":"Codes","measured":true,"value":[1.0,2.0],"sampleSize":2,"codes":[{"code":429,"count":2}]}) diff --git a/packages/rust/tests/voip.rs b/packages/rust/tests/voip.rs index a82cbed1..477ccae1 100644 --- a/packages/rust/tests/voip.rs +++ b/packages/rust/tests/voip.rs @@ -342,6 +342,6 @@ fn simultaneous_claim_verdicts_and_evidence() { serde_json::to_value(webhook).unwrap(), json!("simultaneous_calls") ); - let evidence: polymorfa_sdk::bansafe::ClaimEvidence = serde_json::from_value(json!({"attributionRuleVersion":null,"windowDays":30,"deviceEvidence":false,"otherDevices":0,"restrictedInWindow":false,"criticalFindingDays":0,"sharedConnection":false,"simultaneousCalls":true,"measuredHours":0})).unwrap(); + let evidence: polymorfa_sdk::bansafe::ClaimEvidence = serde_json::from_value(json!({"attributionRuleVersion":null,"windowDays":30,"deviceEvidence":false,"otherDevices":0,"restrictedInWindow":false,"criticalFindingDays":0,"sharedConnection":false,"simultaneousCalls":true,"linkedDeviceInteractive":false,"referralGroupSend":false,"measuredHours":0})).unwrap(); assert!(evidence.simultaneous_calls); } diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 6f3807aa..ffb2f0a2 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -318,7 +318,12 @@ Claim `measuredCents`, `capCents`, and `amountCents` are credit quantities with up to six decimal places, not integer cents. A claim with verdict `simultaneous_calls` opens `denied` with amount 0 because simultaneous calls were on for the number during the 30 days before the ban; -`evidence.simultaneousCalls` reports that state for every claim. Finding acknowledgement and +`evidence.simultaneousCalls` reports that state for every claim. The verdicts +`linked_device_interactive` (an interactive message sent from a linked device) +and `referral_group_send` (a referral message posted to WhatsApp groups) also +open `denied` with amount 0 when the send falls in that window; +`evidence.linkedDeviceInteractive` and `evidence.referralGroupSend` report them, +and are `false` on claims filed before they were recorded. Finding acknowledgement and enforcement appeals require a signed-in dashboard session and are not SDK methods. diff --git a/packages/typescript/src/platform/types.ts b/packages/typescript/src/platform/types.ts index 49955461..5dbd148d 100644 --- a/packages/typescript/src/platform/types.ts +++ b/packages/typescript/src/platform/types.ts @@ -1267,7 +1267,19 @@ export type BanSafeClaimVerdict = * Simultaneous calls were on for the number during the 30 days before the * ban. The claim opens `denied` with amount 0 and cannot be approved. */ - | "simultaneous_calls"; + | "simultaneous_calls" + /** + * The number sent an interactive message from a linked device during the 30 + * days before the ban. Ban Credit does not cover it: the claim opens `denied` + * with amount 0 and cannot be approved. + */ + | "linked_device_interactive" + /** + * The number posted a referral message to WhatsApp groups during the 30 days + * before the ban. Ban Credit does not cover it: the claim opens `denied` with + * amount 0 and cannot be approved. + */ + | "referral_group_send"; export interface BanSafeClaimEvidence { readonly attributionRuleVersion: number | null; @@ -1284,6 +1296,19 @@ export interface BanSafeClaimEvidence { * when filed. */ readonly simultaneousCalls: boolean; + /** + * Whether this number sent an interactive message (buttons, lists, product + * messages, Flows and similar) from a linked device inside the window. When + * `true`, Ban Credit does not cover the ban and the claim is denied when + * filed. `false` for claims filed before this was recorded. + */ + readonly linkedDeviceInteractive: boolean; + /** + * Whether this number posted a referral message to WhatsApp groups inside + * the window. When `true`, Ban Credit does not cover the ban and the claim is + * denied when filed. `false` for claims filed before this was recorded. + */ + readonly referralGroupSend: boolean; } export interface BanSafeClaim { diff --git a/packages/typescript/src/webhooks/events.ts b/packages/typescript/src/webhooks/events.ts index db044243..86a0d67d 100644 --- a/packages/typescript/src/webhooks/events.ts +++ b/packages/typescript/src/webhooks/events.ts @@ -941,7 +941,11 @@ export interface BanSafeClaimPayload { | "ours" | "inconclusive" /** Simultaneous calls voided Ban Insurance; the claim is denied when filed. */ - | "simultaneous_calls"; + | "simultaneous_calls" + /** An interactive message sent from a linked device voided Ban Credit; the claim is denied when filed. */ + | "linked_device_interactive" + /** A referral message posted to WhatsApp groups voided Ban Credit; the claim is denied when filed. */ + | "referral_group_send"; readonly windowStart: string; readonly windowEnd: string; /** Decimal cent amounts with up to six fractional digits. */ diff --git a/packages/typescript/test/ban-credit-waivers-contract.test.ts b/packages/typescript/test/ban-credit-waivers-contract.test.ts new file mode 100644 index 00000000..2724b7b0 --- /dev/null +++ b/packages/typescript/test/ban-credit-waivers-contract.test.ts @@ -0,0 +1,87 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, expectTypeOf, it } from "vitest"; + +import type { + BanSafeClaim, + BanSafeClaimEvidence, + BanSafeClaimPayload, + BanSafeClaimVerdict, + WebhookPayloadMap, +} from "../src/index.js"; + +interface Supplement { + readonly sourceCommit: string; + readonly verdicts: Readonly< + Record< + "BanSafeClaim" | "BanSafeClaimPayload", + { readonly enum: readonly string[] } + > + >; + readonly evidence: { + readonly BanSafeClaimEvidence: { + readonly required: readonly string[]; + readonly properties: Readonly>; + }; + }; +} + +// Pinned to the API source revision that adds the two Ban Credit waivers. +const supplement = JSON.parse( + readFileSync( + fileURLToPath( + new URL("../../../contracts/ban-credit-waivers.json", import.meta.url), + ), + "utf8", + ), +) as Supplement; + +// Exhaustive maps: adding or removing a verdict in the SDK types without +// updating these fails the typecheck, and the tests compare them to the API. +const CLAIM_VERDICTS: Record = { + other_device: true, + customer_conduct: true, + shared_network: true, + ours: true, + inconclusive: true, + simultaneous_calls: true, + linked_device_interactive: true, + referral_group_send: true, +}; +const PAYLOAD_VERDICTS: Record = { + ...CLAIM_VERDICTS, +}; + +describe("Ban Credit waiver claim contract", () => { + it("types every claim verdict the API returns", () => { + expect(Object.keys(CLAIM_VERDICTS).sort()).toEqual( + [...supplement.verdicts.BanSafeClaim.enum].sort(), + ); + expect(Object.keys(PAYLOAD_VERDICTS).sort()).toEqual( + [...supplement.verdicts.BanSafeClaimPayload.enum].sort(), + ); + }); + + it("uses one verdict type for claim reads and the bansafe.claim webhook", () => { + expectTypeOf< + BanSafeClaim["verdict"] + >().toEqualTypeOf(); + expectTypeOf< + WebhookPayloadMap["bansafe.claim"]["verdict"] + >().toEqualTypeOf(); + }); + + it("types the waiver evidence flags as required booleans", () => { + const evidence = supplement.evidence.BanSafeClaimEvidence; + for (const field of ["linkedDeviceInteractive", "referralGroupSend"]) { + expect(evidence.required).toContain(field); + expect(evidence.properties[field]?.type).toBe("boolean"); + } + expectTypeOf< + BanSafeClaimEvidence["linkedDeviceInteractive"] + >().toEqualTypeOf(); + expectTypeOf< + BanSafeClaimEvidence["referralGroupSend"] + >().toEqualTypeOf(); + }); +}); diff --git a/packages/typescript/test/calls-simultaneous-calls-contract.test.ts b/packages/typescript/test/calls-simultaneous-calls-contract.test.ts index 908fc030..015eb3b0 100644 --- a/packages/typescript/test/calls-simultaneous-calls-contract.test.ts +++ b/packages/typescript/test/calls-simultaneous-calls-contract.test.ts @@ -43,8 +43,16 @@ const platform = contract.sources.platform.schemas; const keys = (schema: Schema | undefined) => Object.keys(schema?.properties ?? {}).sort(); -// Every verdict must appear here; a missing or extra key fails to compile. -const VERDICTS: Record = { +// Every verdict at this supplement's API revision must appear here; a missing +// or extra key fails to compile. Verdicts added later are pinned by their own +// supplement (ban-credit-waivers.json). +const VERDICTS: Record< + Exclude< + BanSafeClaimVerdict, + "linked_device_interactive" | "referral_group_send" + >, + true +> = { other_device: true, customer_conduct: true, shared_network: true,