@peable.to/shared-types 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,224 +1,127 @@
1
1
  /** {@link PaymentIntentRail} as the tuple the database CHECK reads. */
2
2
  export const PAYMENT_INTENT_RAILS = ['faircoin', 'card'];
3
3
  /**
4
- * Which statuses belong to which rail (ADR 0001 D5).
4
+ * The statuses only a CARD payment can reach (ADR 0019).
5
5
  *
6
- * The four chain states describe a transaction on a blockchain and the four
7
- * card states describe an authorization at a card acquirer; neither set is
8
- * expressible on the other rail. Mapping a card authorization onto `broadcast`
9
- * would be cheaper and would be a lie — nothing was broadcast, there is no
10
- * transaction, and `payment_intents_broadcast_requires_txid_check` would then
11
- * be demanding a txid from a payment that can never have one.
12
- *
13
- * `db/schema/payments.ts` renders these two sets into CHECK constraints, so the
14
- * restriction holds against a write that skipped the application.
6
+ * A FairCoin payment has no payment method to require (the payer's wallet signs
7
+ * on their own device), nothing to confirm server-side, and no authorization
8
+ * to capture — coins either arrive or they do not. `db/schema/payments.ts`
9
+ * renders this set into a CHECK, so the restriction holds against a write that
10
+ * skipped the application.
15
11
  */
16
- export const CHAIN_ONLY_STATUSES = [
17
- 'awaiting_approval',
18
- 'approved',
19
- 'broadcast',
20
- 'confirming',
21
- ];
22
12
  export const CARD_ONLY_STATUSES = [
23
- 'requires_action',
24
- 'processing',
25
- 'refunded',
26
- 'partially_refunded',
13
+ 'requires_payment_method',
14
+ 'requires_confirmation',
15
+ 'requires_capture',
27
16
  ];
28
17
  /**
29
- * Legal forward transitions of the PaymentIntent lifecycle. Terminal states
30
- * (refunded/expired/failed/rejected) allow no further transition here — the one
31
- * documented exception, a reorg dropping `settled` back to `confirming`, is
32
- * modelled by the backend state machine, not this happy-path table.
18
+ * Legal transitions of the PaymentIntent lifecycle.
33
19
  *
34
- * ONE table for both rails, deliberately. It answers "is this a legal lifecycle
35
- * edge at all"; WHICH rail may reach a given status is the separate, structural
36
- * question the two sets above answer, in the database. A rail-parameterised
37
- * table would put the same restriction in two places and let them disagree.
20
+ * ONE table for both rails. It answers "is this a legal lifecycle edge at all";
21
+ * WHICH rail may reach a given status is the separate, structural question
22
+ * {@link CARD_ONLY_STATUSES} answers in the database, and which EVENT may walk
23
+ * an edge is `LEGAL_SOURCES` in the backend's `services/intentState.ts`.
38
24
  *
39
25
  * Self-transitions are absent because `applyEvent` short-circuits on
40
- * `current === target` before consulting this table — that is the idempotent
41
- * re-poll of the settlement watcher, and a second partial refund, which change
42
- * an amount without changing a status.
26
+ * `current === target` — the idempotent re-poll of the settlement watcher.
27
+ *
28
+ * `succeeded` has no outgoing edge here. Its one exception — a chain reorg
29
+ * dropping a FairCoin payment back below its confirmation threshold,
30
+ * `succeeded → processing` — is modelled by the backend state machine, not this
31
+ * table. Refunds are not statuses: money coming back is `amount_refunded` and
32
+ * the refunds themselves (ADR 0019).
43
33
  */
44
34
  const ALLOWED = {
45
- // `created` supports three paths: the rich wallet flow (→ awaiting_approval →
46
- // approved → broadcast), the minimal path where the payer pays out-of-band
47
- // and reports the txid directly (→ broadcast), and the card path (→
48
- // requires_action for an SCA challenge, → processing, or straight to settled
49
- // for a charge that confirms in one call). `failed` is reachable from here
50
- // because a card can be declined on first confirmation, having never been
51
- // anything else; no FairCoin path uses that edge, since `underpaid` is only
52
- // emitted for an intent that already carries a txid.
53
- created: [
54
- 'awaiting_approval',
55
- 'broadcast',
35
+ requires_payment_method: [
36
+ 'requires_confirmation',
37
+ 'requires_action',
38
+ 'processing',
39
+ 'requires_capture',
40
+ 'succeeded',
41
+ 'canceled',
42
+ ],
43
+ requires_confirmation: [
44
+ 'requires_payment_method',
56
45
  'requires_action',
57
46
  'processing',
58
- 'settled',
59
- 'failed',
60
- 'rejected',
61
- 'expired',
47
+ 'requires_capture',
48
+ 'succeeded',
49
+ 'canceled',
62
50
  ],
63
- awaiting_approval: ['approved', 'rejected', 'expired'],
64
- approved: ['broadcast', 'failed'],
65
- broadcast: ['confirming', 'failed'],
66
- confirming: ['settled', 'failed'],
67
- requires_action: ['processing', 'settled', 'failed', 'expired', 'rejected'],
68
- processing: ['settled', 'failed'],
69
- /**
70
- * A failed ATTEMPT is not a failed payment, on the card rail.
71
- *
72
- * `payment_intent.payment_failed` means one authorization attempt was
73
- * declined; Stripe returns the PaymentIntent to `requires_payment_method` and
74
- * the payer can confirm it again with another card. With `failed` terminal,
75
- * the later `payment_intent.succeeded` was an ILLEGAL transition — so the
76
- * drain recorded a processing failure and retried it forever, the payment
77
- * stayed `failed`, and a merchant who had been paid was told their payment
78
- * had been declined.
79
- *
80
- * On the chain rail `failed` means `underpaid`, which IS terminal — coins
81
- * arrived and were not enough. Nothing here opens that door: `underpaid` is
82
- * emitted only by the settlement watcher, and `LEGAL_SOURCES` in
83
- * `services/intentState.ts` keeps every `card_*` event to card statuses and
84
- * the chain's `confirmed` to `confirming`. This table answers "is the edge
85
- * legal at all"; which rail may walk it is the separate question those two
86
- * guards answer.
87
- */
88
- failed: ['requires_action', 'processing', 'settled'],
89
- // Money coming back requires money to have arrived, so both refund states are
90
- // reachable only from `settled`.
91
- settled: ['refunded', 'partially_refunded'],
92
- /**
93
- * ...and money coming back can be UNDONE, which is why these edges run both
94
- * ways.
95
- *
96
- * A refund is not final when the provider accepts it: a bank can reject one
97
- * days later, and the provider then says so (`refund.failed`). Without a path
98
- * back, a payment whose only refund failed would claim `refunded` forever —
99
- * the merchant's books saying money went back that is still with them, and no
100
- * transition anywhere able to correct it.
101
- *
102
- * The target is always recomputed from the SUM of succeeded refunds
103
- * (`applyRefundToIntent`), never stepped, so these edges describe where that
104
- * sum can legally land rather than a sequence anyone walks.
105
- */
106
- partially_refunded: ['refunded', 'settled'],
107
- refunded: ['partially_refunded', 'settled'],
108
- expired: [],
109
- rejected: [],
51
+ // FairCoin: the payer broadcasts (→ processing), or the intent is cancelled
52
+ // or expires (→ canceled). Card: an SCA challenge resolved either way.
53
+ requires_action: [
54
+ 'requires_payment_method',
55
+ 'processing',
56
+ 'requires_capture',
57
+ 'succeeded',
58
+ 'canceled',
59
+ ],
60
+ // FairCoin: confirmed (→ succeeded) or underpaid (→ canceled). Card: an async
61
+ // method settled, was authorized, or failed back to `requires_payment_method`.
62
+ processing: ['requires_payment_method', 'requires_capture', 'succeeded', 'canceled'],
63
+ requires_capture: ['processing', 'succeeded', 'canceled'],
64
+ succeeded: [],
65
+ canceled: [],
110
66
  };
111
67
  export function isValidStatusTransition(from, to) {
112
68
  return ALLOWED[from].includes(to);
113
69
  }
114
- /** Every legal `PaymentIntentStatus`, derived from `ALLOWED`'s keys — the
115
- * single source of truth consumed by the Gateway's list-route status filter. */
70
+ /** Every `PaymentIntentStatus`, derived from `ALLOWED`'s keys. */
116
71
  export const PAYMENT_INTENT_STATUSES = Object.keys(ALLOWED);
117
- /**
118
- * The statuses a payer can still complete a payment FROM.
119
- *
120
- * **Twice now this question has been answered by walking the transition table,
121
- * and twice the table has moved underneath it.**
122
- *
123
- * The hosted checkout first asked leaf-ness — "has this status no outgoing
124
- * edges" — which meant `settled` while `settled` had none. Adding the refund
125
- * transitions (`settled → refunded | partially_refunded`) gave it edges, so a
126
- * settled payment stopped looking finished, and the checkout began REUSING a
127
- * remembered settled intent instead of minting a fresh one: a payer who had
128
- * already paid saw their old receipt forever, with no way to pay the link
129
- * again.
130
- *
131
- * The fix was reachability — "is `settled` still reachable" — and that held
132
- * until refunds could be UNDONE. `refunded → settled` (a bank rejecting a
133
- * refund days later) makes `settled` reachable from `settled` in two steps, so
134
- * reachability answers `true` for a payment that is finished, and the original
135
- * bug returns unchanged.
136
- *
137
- * So the set is stated, not derived. It is exactly the statuses in which the
138
- * gateway is still waiting on the PAYER — which is what the question means, in
139
- * the payer's own terms, and is the one formulation no edge added elsewhere can
140
- * silently change. `paymentIntent.test.ts` pins it against the table: every
141
- * member must still be able to reach `settled` without passing through a
142
- * post-settlement status, and no non-member may.
143
- */
144
- const PAYABLE_STATUSES = new Set([
145
- 'created',
146
- 'awaiting_approval',
147
- 'approved',
148
- 'broadcast',
149
- 'confirming',
150
- 'requires_action',
151
- 'processing',
152
- ]);
72
+ /** The statuses that end a payment: nothing leaves them (bar the reorg exception). */
73
+ export const TERMINAL_STATUSES = ['succeeded', 'canceled'];
153
74
  /**
154
75
  * Whether a payer can still complete a payment against an intent in this
155
- * status.
156
- *
157
- * `settled` answers `false`: it is where paying ENDS. `partially_refunded`
158
- * answers `false` too — money can still move, but not from the payer.
76
+ * status — every non-terminal one. Stated, not derived from reachability:
77
+ * `paymentIntent.test.ts` pins it against the table.
159
78
  */
160
79
  export function canStillBePaid(status) {
161
- return PAYABLE_STATUSES.has(status);
162
- }
163
- /**
164
- * The statuses a payment reaches only AFTER the payer has finished.
165
- *
166
- * Exported for the gate below rather than for production use: the property that
167
- * makes `PAYABLE_STATUSES` checkable is that a payable status reaches `settled`
168
- * without going through one of these.
169
- */
170
- export const POST_SETTLEMENT_STATUSES = [
171
- 'settled',
172
- 'refunded',
173
- 'partially_refunded',
174
- ];
175
- /**
176
- * Whether `settled` is reachable from `status` without passing through a
177
- * post-settlement status. The derivation `canStillBePaid` used to BE, kept as
178
- * the thing that checks it rather than as the thing that answers it.
179
- */
180
- export function reachesSettlementFromPayer(status) {
181
- const blocked = new Set(POST_SETTLEMENT_STATUSES);
182
- // A payment that has ALREADY settled is one the payer has finished with,
183
- // whatever edges lead out of it. Checked before the walk rather than inside
184
- // it, because `partially_refunded → settled` is a direct edge and the walk
185
- // would find it in one step — and that edge is a refund being undone, not a
186
- // payer paying.
187
- if (blocked.has(status))
188
- return false;
189
- const seen = new Set();
190
- const queue = [...ALLOWED[status]];
191
- while (queue.length > 0) {
192
- const next = queue.shift();
193
- if (next === 'settled')
194
- return true;
195
- if (seen.has(next) || blocked.has(next))
196
- continue;
197
- seen.add(next);
198
- queue.push(...ALLOWED[next]);
199
- }
200
- return false;
80
+ return !TERMINAL_STATUSES.includes(status);
201
81
  }
202
82
  /**
203
83
  * The statuses an intent may still EXPIRE from (ADR 0012 D9).
204
84
  *
205
- * Exactly those whose transition list contains `expired`: the gateway is
206
- * waiting on a PERSON who has not paid. Once a FairCoin payer has reported a
207
- * broadcast (`broadcast`), or the chain is confirming it (`confirming`), the
208
- * intent NEVER expires, whatever `expiresAt` says — coins are moving and the
209
- * settlement watcher owns the row. A platform must therefore never cancel an
210
- * order because `expiresAt` passed: it releases a reservation on
211
- * `payment_intent.expired` (or `rejected`), and on nothing else.
85
+ * The gateway is waiting on a PERSON who has not paid: a FairCoin payer who has
86
+ * not broadcast (`requires_action`), or a card payer who has not completed a
87
+ * payment method or a challenge. Once a FairCoin payer has reported a
88
+ * transaction (`processing`) the intent NEVER expires, whatever `expires_at`
89
+ * says — coins are moving and the settlement watcher owns the row. An
90
+ * authorization held for capture (`requires_capture`) is the merchant's to
91
+ * capture or cancel and does not expire either.
212
92
  *
213
- * Stated rather than derived, for `PAYABLE_STATUSES`' reason; the gateway's
214
- * test suite pins it against the transition table.
93
+ * Expiry is a cancellation with `cancellation_reason: 'abandoned'`: a platform
94
+ * releases a reservation on `payment_intent.canceled`.
215
95
  */
216
96
  export const EXPIRABLE_STATUSES = [
217
- 'created',
218
- 'awaiting_approval',
97
+ 'requires_payment_method',
98
+ 'requires_confirmation',
219
99
  'requires_action',
220
100
  ];
221
101
  /** Whether an intent in this status can still expire. See {@link EXPIRABLE_STATUSES}. */
222
102
  export function canExpire(status) {
223
103
  return EXPIRABLE_STATUSES.includes(status);
224
104
  }
105
+ export const PAYMENT_INTENT_CANCELLATION_REASONS = [
106
+ 'abandoned',
107
+ 'automatic',
108
+ 'duplicate',
109
+ 'fraudulent',
110
+ 'requested_by_customer',
111
+ 'underpaid',
112
+ ];
113
+ /** The reasons a MERCHANT may give `POST /v1/payment_intents/:id/cancel`. */
114
+ export const MERCHANT_CANCELLATION_REASONS = [
115
+ 'abandoned',
116
+ 'duplicate',
117
+ 'fraudulent',
118
+ 'requested_by_customer',
119
+ ];
120
+ export const PAYMENT_INTENT_CAPTURE_METHODS = [
121
+ 'automatic',
122
+ 'manual',
123
+ ];
124
+ export const PAYMENT_INTENT_SETUP_FUTURE_USAGES = [
125
+ 'on_session',
126
+ 'off_session',
127
+ ];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ export const SETUP_INTENT_STATUSES = [
2
+ 'requires_payment_method',
3
+ 'requires_confirmation',
4
+ 'requires_action',
5
+ 'processing',
6
+ 'canceled',
7
+ 'succeeded',
8
+ ];
@@ -0,0 +1,17 @@
1
+ export const WEBHOOK_ENDPOINT_STATUSES = ['enabled', 'disabled'];
2
+ /**
3
+ * Why an endpoint is `disabled`.
4
+ *
5
+ * - `disabled_by_merchant` — its owner set `disabled: true`.
6
+ * - `delivery_failures` — every delivery to it failed for three days
7
+ * straight, so Peable stopped sending. Re-enabling it (`disabled: false`)
8
+ * starts the clock again.
9
+ */
10
+ export const WEBHOOK_ENDPOINT_DISABLED_REASONS = [
11
+ 'disabled_by_merchant',
12
+ 'delivery_failures',
13
+ ];
14
+ /** At most this many endpoints per merchant (per mode). */
15
+ export const WEBHOOK_ENDPOINTS_MAX_PER_MERCHANT = 16;
16
+ /** The longest a rolled secret may keep signing beside its replacement. */
17
+ export const WEBHOOK_SECRET_ROLL_MAX_EXPIRES_IN_SECONDS = 24 * 60 * 60;
@@ -9,32 +9,40 @@ function computeSignature(secret, rawBody, timestamp) {
9
9
  }
10
10
  /**
11
11
  * Produce a Stripe-style signature header for an outbound webhook:
12
- * `t=${timestamp},v1=${hexHmacSha256}`.
12
+ * `t=${timestamp},v1=${hex}` — with one `v1=` per secret.
13
+ *
14
+ * Several secrets are what make a rotation gapless: while an endpoint's rolled
15
+ * secret is still inside its overlap window, every delivery is signed with
16
+ * BOTH, so a receiver verifying with either one accepts it.
13
17
  */
14
18
  export function signWebhook(secret, rawBody, timestamp) {
15
- return `t=${timestamp},v1=${computeSignature(secret, rawBody, timestamp)}`;
19
+ const secrets = typeof secret === 'string' ? [secret] : secret;
20
+ if (secrets.length === 0)
21
+ throw new Error('signWebhook needs at least one secret');
22
+ const signatures = secrets.map((s) => `v1=${computeSignature(s, rawBody, timestamp)}`);
23
+ return `t=${timestamp},${signatures.join(',')}`;
16
24
  }
17
25
  function parseHeader(header) {
18
26
  let timestamp = null;
19
- let signature = null;
27
+ const signatures = [];
20
28
  for (const part of header.split(',')) {
21
29
  const separator = part.indexOf('=');
22
30
  if (separator === -1)
23
31
  continue;
24
- const key = part.slice(0, separator);
25
- const value = part.slice(separator + 1);
32
+ const key = part.slice(0, separator).trim();
33
+ const value = part.slice(separator + 1).trim();
26
34
  if (key === 't') {
27
35
  const parsed = Number(value);
28
36
  if (Number.isInteger(parsed))
29
37
  timestamp = parsed;
30
38
  }
31
39
  else if (key === 'v1') {
32
- signature = value;
40
+ signatures.push(value);
33
41
  }
34
42
  }
35
- if (timestamp === null || signature === null)
43
+ if (timestamp === null || signatures.length === 0)
36
44
  return null;
37
- return { timestamp, signature };
45
+ return { timestamp, signatures };
38
46
  }
39
47
  /**
40
48
  * Constant-time hex-string comparison. Returns false for unequal-length inputs
@@ -49,9 +57,13 @@ function constantTimeEqualHex(a, b) {
49
57
  }
50
58
  /**
51
59
  * Verify a webhook signature header against the raw body.
52
- * - Parses `t` and `v1` from the header.
60
+ *
61
+ * - Parses `t` and EVERY `v1` from the header.
53
62
  * - Rejects if the timestamp drifts more than `toleranceSec` from `nowSec`.
54
- * - Recomputes the expected signature and compares it in constant time.
63
+ * - Accepts when any `v1` matches the signature computed with any of
64
+ * `secret` — so a receiver mid-rotation can pass both of its secrets, and a
65
+ * delivery signed during the sender's overlap window verifies against
66
+ * either.
55
67
  */
56
68
  export function verifyWebhook(secret, rawBody, header, toleranceSec, nowSec) {
57
69
  const parsed = parseHeader(header);
@@ -59,6 +71,17 @@ export function verifyWebhook(secret, rawBody, header, toleranceSec, nowSec) {
59
71
  return false;
60
72
  if (Math.abs(nowSec - parsed.timestamp) > toleranceSec)
61
73
  return false;
62
- const expected = computeSignature(secret, rawBody, parsed.timestamp);
63
- return constantTimeEqualHex(expected, parsed.signature);
74
+ const secrets = typeof secret === 'string' ? [secret] : secret;
75
+ let matched = false;
76
+ // Every comparison runs, so the time taken does not say which one matched.
77
+ for (const candidate of secrets) {
78
+ if (candidate.length === 0)
79
+ continue;
80
+ const expected = computeSignature(candidate, rawBody, parsed.timestamp);
81
+ for (const signature of parsed.signatures) {
82
+ if (constantTimeEqualHex(expected, signature))
83
+ matched = true;
84
+ }
85
+ }
86
+ return matched;
64
87
  }
@@ -0,0 +1,56 @@
1
+ import type { CurrencyCode } from './money';
2
+ import type { PaymentIntent } from './paymentIntent';
3
+ /** Card details safe to show: never the number, never the CVC. */
4
+ export interface CardDetails {
5
+ brand: string | null;
6
+ last4: string | null;
7
+ exp_month: number | null;
8
+ exp_year: number | null;
9
+ /** ISO 3166-1 alpha-2 of the issuer. */
10
+ country: string | null;
11
+ /** `credit`, `debit`, `prepaid` or `unknown`. */
12
+ funding: string | null;
13
+ }
14
+ export interface ChargeOutcome {
15
+ /** `approved_by_network`, `declined_by_network`, `not_sent_to_network`, `reversed_after_approval`. */
16
+ network_status: string | null;
17
+ /** `normal`, `elevated`, `highest`, or `not_assessed`. */
18
+ risk_level: string | null;
19
+ /** `authorized`, `manual_review`, `issuer_declined`, `blocked`, `invalid`. */
20
+ type: string | null;
21
+ /** Peable's own one-line explanation for the merchant — never the provider's prose. */
22
+ seller_message: string | null;
23
+ }
24
+ export interface Charge {
25
+ id: string;
26
+ object: 'charge';
27
+ livemode: boolean;
28
+ /** `pi_…`, or the {@link PaymentIntent} with `expand[]=payment_intent`. */
29
+ payment_intent: string | PaymentIntent;
30
+ customer: string | null;
31
+ payment_method: string | null;
32
+ amount: string;
33
+ amount_captured: string;
34
+ amount_refunded: string;
35
+ currency: CurrencyCode;
36
+ /** Always `succeeded`: Peable records the authorization that succeeded, not failed attempts. */
37
+ status: 'succeeded';
38
+ paid: boolean;
39
+ captured: boolean;
40
+ /** All of it came back — by refunds, or by releasing an uncaptured authorization. */
41
+ refunded: boolean;
42
+ disputed: boolean;
43
+ outcome: ChargeOutcome | null;
44
+ payment_method_details: {
45
+ type: 'card';
46
+ card: CardDetails;
47
+ } | null;
48
+ /** Peable-hosted receipts land with Phase 3; the provider's own URL never reaches the wire. */
49
+ receipt_url: string | null;
50
+ /** A Peable balance transaction (`txn_…`) once the ledger exists (Phase 5). */
51
+ balance_transaction: string | null;
52
+ description: string | null;
53
+ receipt_email: string | null;
54
+ metadata: Record<string, string>;
55
+ created: number;
56
+ }
@@ -0,0 +1,39 @@
1
+ import type { Address, AddressParams, Shipping, ShippingParams } from './paymentIntent';
2
+ import type { PaymentMethod } from './paymentMethod';
3
+ export interface Customer {
4
+ id: string;
5
+ object: 'customer';
6
+ livemode: boolean;
7
+ email: string | null;
8
+ name: string | null;
9
+ phone: string | null;
10
+ description: string | null;
11
+ address: Address | null;
12
+ shipping: Shipping | null;
13
+ invoice_settings: {
14
+ /** `pm_…`, or the {@link PaymentMethod} with `expand[]=invoice_settings.default_payment_method`. */
15
+ default_payment_method: string | PaymentMethod | null;
16
+ };
17
+ metadata: Record<string, string>;
18
+ created: number;
19
+ }
20
+ /** A deleted customer, as `DELETE /v1/customers/:id` answers. */
21
+ export interface DeletedCustomer {
22
+ id: string;
23
+ object: 'customer';
24
+ deleted: true;
25
+ }
26
+ export interface CreateCustomerParams {
27
+ email?: string;
28
+ name?: string;
29
+ phone?: string;
30
+ description?: string;
31
+ address?: AddressParams;
32
+ shipping?: ShippingParams;
33
+ invoice_settings?: {
34
+ default_payment_method?: string;
35
+ };
36
+ metadata?: Record<string, string>;
37
+ }
38
+ /** `POST /v1/customers/:id`. A field sent as `""` is cleared; `metadata` is a patch. */
39
+ export type UpdateCustomerParams = CreateCustomerParams;
@@ -3,8 +3,8 @@ import type { PaymentIntent } from './paymentIntent';
3
3
  * A dispute — the card network holding a settled payment's money while a
4
4
  * cardholder contests it.
5
5
  *
6
- * Published because `payment_intent.disputed` and `payment_intent.dispute_closed`
7
- * carry it as their `data.object`. It was route-local at first, with the webhook
6
+ * Published because `charge.dispute.created`, `charge.dispute.updated` and
7
+ * `charge.dispute.closed` carry it as their `data.object`. It was route-local at first, with the webhook
8
8
  * carrying the PaymentIntent and the detail reachable only through
9
9
  * `GET /v1/payment_intents/:id/disputes`. That was inconsistent with the reason
10
10
  * those two events exist: `disputed` is a DEADLINE, and a merchant who has to