@volter/twin-stripe 0.1.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.
- package/LICENSE +202 -0
- package/README.md +110 -0
- package/client/stripe-mirror.css +162 -0
- package/client/stripe-mirror.tsx +691 -0
- package/package.json +71 -0
- package/src/cli.ts +29 -0
- package/src/index.ts +52 -0
- package/src/stripe-capabilities.ts +3304 -0
- package/src/stripe-conformance.ts +110 -0
- package/src/stripe-connector.ts +373 -0
- package/src/stripe-events.ts +202 -0
- package/src/stripe-form.ts +35 -0
- package/src/stripe-mirror-ui.ts +345 -0
- package/src/stripe-server.ts +46 -0
- package/src/stripe-twin.ts +5227 -0
- package/src/stripe-ui-conformance.ts +125 -0
- package/src/stripe-ui-structure.ts +406 -0
- package/test-fixtures/stripe-known-deviations.json +85 -0
- package/test-fixtures/stripe-schemas.json +3364 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Stripe UI conformance — the declared UI surface inventory for a Stripe dashboard,
|
|
2
|
+
// classified against THIS twin, and the read-only completeness check over it.
|
|
3
|
+
//
|
|
4
|
+
// Honesty: `status` is the current truth, verified by the structural cross-check in
|
|
5
|
+
// stripe-ui-conformance.test.ts (which builds the actual mirror bundle and asserts
|
|
6
|
+
// each 'rendered' surface's helper/label/class is really present — downgrading to
|
|
7
|
+
// 'modeled' otherwise). `rendered` = the mirror shows it today; `modeled` = the twin
|
|
8
|
+
// has the data but the mirror doesn't render it (a UI gap to close); `unmodeled` =
|
|
9
|
+
// real Stripe shows it but the twin doesn't model it (a twin-model gap to close).
|
|
10
|
+
//
|
|
11
|
+
// Classified against the actual mirror (client/stripe-mirror.tsx + the pure helpers in
|
|
12
|
+
// stripe-mirror-ui.ts). The mirror has nineteen sections — customers, payment_intents,
|
|
13
|
+
// setup_intents, subscriptions, invoices, invoiceitems, products, prices, charges,
|
|
14
|
+
// refunds, payment_methods, disputes, payouts, balance_transactions, balance,
|
|
15
|
+
// connected accounts, transfers, events — plus a Connect account panel (enablement
|
|
16
|
+
// flags + outstanding requirements) and a generic detail panel that renders EVERY
|
|
17
|
+
// scalar field (key/value grid), EVERY
|
|
18
|
+
// nested object/array (flattened), status pills, amount/currency formatting, recurring
|
|
19
|
+
// labels, product image <img> thumbnails, clickable cross-references (outgoing +
|
|
20
|
+
// incoming), a prominent payment-decline banner (last_payment_error) and a synthesized
|
|
21
|
+
// balance summary. So anything the twin stores on those collections is shown.
|
|
22
|
+
import { checkUiCompleteness, type UiSurface, type UiCompletenessReport } from '@volter/twin-tooling';
|
|
23
|
+
|
|
24
|
+
/** What a real Stripe dashboard shows, classified vs the twin + its UI mirror. */
|
|
25
|
+
export const STRIPE_UI_INVENTORY: UiSurface[] = [
|
|
26
|
+
// --- shell / cross-cutting surfaces the mirror provides everywhere ---
|
|
27
|
+
{ key: 'sectionNav', label: 'Left-nav sections (Customers/Payments/Subs/Invoices/Products/Prices) with counts', status: 'rendered' },
|
|
28
|
+
{ key: 'listDetailSplit', label: 'List + detail split per section', status: 'rendered' },
|
|
29
|
+
{ key: 'sectionFilter', label: 'In-section text filter / search', status: 'rendered' },
|
|
30
|
+
{ key: 'statusPill', label: 'Status pill with semantic tone (ok/warn/bad)', status: 'rendered' },
|
|
31
|
+
{ key: 'amountFormatting', label: 'Currency-formatted amounts (zero-decimal aware)', status: 'rendered' },
|
|
32
|
+
{ key: 'nestedFields', label: 'Nested object/array fields flattened (no "[object Object]")', status: 'rendered' },
|
|
33
|
+
{ key: 'crossLinksOutgoing', label: 'Outgoing cross-links (this row → referenced objects)', status: 'rendered' },
|
|
34
|
+
{ key: 'crossLinksIncoming', label: 'Incoming cross-links (objects referencing this row)', status: 'rendered' },
|
|
35
|
+
{ key: 'metadata', label: 'Object metadata key/values', status: 'rendered' },
|
|
36
|
+
|
|
37
|
+
// --- customers ---
|
|
38
|
+
{ key: 'customerName', label: 'Customer name', status: 'rendered' },
|
|
39
|
+
{ key: 'customerEmail', label: 'Customer email', status: 'rendered' },
|
|
40
|
+
// Customer credit balance is now modeled (customer.balance + a balance-transaction ledger)
|
|
41
|
+
// and rendered as its own "Customer Balance" mirror section (amount + running ending_balance).
|
|
42
|
+
{ key: 'customerBalance', label: 'Customer account balance (credit-balance ledger)', status: 'rendered' },
|
|
43
|
+
// Customer tax IDs (eu_vat / us_ein / …) are a modeled customer sub-resource with their
|
|
44
|
+
// own "Customer Tax IDs" mirror section (value + type + owning customer cross-link).
|
|
45
|
+
{ key: 'customerTaxIds', label: 'Customer tax IDs (value / type)', status: 'rendered' },
|
|
46
|
+
|
|
47
|
+
// --- payments (payment_intents) ---
|
|
48
|
+
{ key: 'paymentList', label: 'Payments list (amount + id)', status: 'rendered' },
|
|
49
|
+
{ key: 'paymentStatus', label: 'Payment status (succeeded/requires_*/…)', status: 'rendered' },
|
|
50
|
+
{ key: 'paymentAmount', label: 'Payment amount (formatted)', status: 'rendered' },
|
|
51
|
+
{ key: 'paymentCustomerLink', label: 'Payment → customer cross-link', status: 'rendered' },
|
|
52
|
+
// test-card decline state: last_payment_error rendered as a prominent error banner.
|
|
53
|
+
{ key: 'paymentDecline', label: 'Payment decline reason (last_payment_error banner)', status: 'rendered' },
|
|
54
|
+
|
|
55
|
+
// --- setup_intents (off-session card save) ---
|
|
56
|
+
{ key: 'setupIntents', label: 'Setup intents list + status (requires_confirmation/succeeded)', status: 'rendered' },
|
|
57
|
+
|
|
58
|
+
// --- subscriptions ---
|
|
59
|
+
{ key: 'subscriptionStatus', label: 'Subscription status (active/trialing/past_due/…)', status: 'rendered' },
|
|
60
|
+
{ key: 'subscriptionCustomerLink', label: 'Subscription → customer cross-link', status: 'rendered' },
|
|
61
|
+
// items + period are nested objects the twin stores and the flattener renders.
|
|
62
|
+
{ key: 'subscriptionItems', label: 'Subscription line items (nested)', status: 'rendered' },
|
|
63
|
+
{ key: 'subscriptionPeriod', label: 'Billing-cycle / period fields', status: 'rendered' },
|
|
64
|
+
|
|
65
|
+
// --- invoices ---
|
|
66
|
+
{ key: 'invoiceStatus', label: 'Invoice status (draft/open/paid/void/…)', status: 'rendered' },
|
|
67
|
+
{ key: 'invoiceTotal', label: 'Invoice total / amount_due (formatted)', status: 'rendered' },
|
|
68
|
+
{ key: 'invoiceLines', label: 'Invoice line items (nested list)', status: 'rendered' },
|
|
69
|
+
{ key: 'invoiceCustomerLink', label: 'Invoice → customer cross-link', status: 'rendered' },
|
|
70
|
+
// invoiceitems are a full twin resource with their own list endpoint + nav section.
|
|
71
|
+
{ key: 'invoiceItems', label: 'Invoice items list (pending line items)', status: 'rendered' },
|
|
72
|
+
// credit notes are a full twin resource (create/preview/void/list) with their own nav
|
|
73
|
+
// section + list/detail; rows show the credited amount + status, with an invoice cross-link.
|
|
74
|
+
{ key: 'creditNotes', label: 'Credit notes list (credited amount + status)', status: 'rendered' },
|
|
75
|
+
{ key: 'creditNoteLines', label: 'Credit note line items (nested)', status: 'rendered' },
|
|
76
|
+
|
|
77
|
+
// --- products ---
|
|
78
|
+
{ key: 'productName', label: 'Product name', status: 'rendered' },
|
|
79
|
+
// images is an array of URLs; the detail view now renders them as <img> thumbnails.
|
|
80
|
+
{ key: 'productImageThumbs', label: 'Product image thumbnails (rendered as <img>)', status: 'rendered' },
|
|
81
|
+
|
|
82
|
+
// --- prices ---
|
|
83
|
+
{ key: 'priceUnitAmount', label: 'Price unit amount (formatted)', status: 'rendered' },
|
|
84
|
+
{ key: 'priceRecurring', label: 'Price recurring interval ("every month")', status: 'rendered' },
|
|
85
|
+
{ key: 'priceProductLink', label: 'Price → product cross-link', status: 'rendered' },
|
|
86
|
+
|
|
87
|
+
// --- collections the twin models, now each with its own nav section + list/detail ---
|
|
88
|
+
// charges, refunds and payment_methods are full twin resources with list endpoints;
|
|
89
|
+
// stripe-mirror.tsx now has a nav section + list/detail for each.
|
|
90
|
+
{ key: 'charges', label: 'Charges list + status', status: 'rendered' },
|
|
91
|
+
{ key: 'refunds', label: 'Refunds list (refund objects)', status: 'rendered' },
|
|
92
|
+
{ key: 'paymentMethods', label: 'Saved payment methods (cards)', status: 'rendered' },
|
|
93
|
+
|
|
94
|
+
// disputes/payouts/balance_transactions/events are full twin resources with list +
|
|
95
|
+
// CRUD/action endpoints (stripe-twin.ts) and vendor-faithful schemas; the mirror now
|
|
96
|
+
// renders a nav section + list/detail for each.
|
|
97
|
+
{ key: 'disputes', label: 'Disputes', status: 'rendered' },
|
|
98
|
+
{ key: 'payouts', label: 'Payouts', status: 'rendered' },
|
|
99
|
+
{ key: 'balanceTransactions', label: 'Balance transactions ledger', status: 'rendered' },
|
|
100
|
+
// the synthesized account balance summary (GET /v1/balance) has its own nav section
|
|
101
|
+
// with available/pending buckets per currency.
|
|
102
|
+
{ key: 'balanceSummary', label: 'Account balance summary (available/pending)', status: 'rendered' },
|
|
103
|
+
{ key: 'eventsLog', label: 'Events / activity log', status: 'rendered' },
|
|
104
|
+
|
|
105
|
+
// --- Connect (connected accounts + transfers) — full twin resources with their own
|
|
106
|
+
// nav sections + list/detail; the account detail surfaces the enablement flags +
|
|
107
|
+
// outstanding requirements, transfers cross-link to their destination account. ---
|
|
108
|
+
{ key: 'connectAccounts', label: 'Connect / connected accounts list + detail', status: 'rendered' },
|
|
109
|
+
{ key: 'connectAccountFlags', label: 'Connected-account enablement flags (charges/payouts/details_submitted)', status: 'rendered' },
|
|
110
|
+
{ key: 'connectAccountRequirements', label: 'Connected-account outstanding onboarding requirements (currently_due)', status: 'rendered' },
|
|
111
|
+
{ key: 'connectTransfers', label: 'Transfers list (platform → connected account)', status: 'rendered' },
|
|
112
|
+
{ key: 'connectTransferDestinationLink', label: 'Transfer → destination connected-account cross-link', status: 'rendered' },
|
|
113
|
+
|
|
114
|
+
// --- Stripe Tax (tax rates + calculations) — full twin resources with their own nav
|
|
115
|
+
// sections + list/detail; rates show display_name/percentage/inclusive, calculations
|
|
116
|
+
// show amount_total + tax + their priced line_items (nested). ---
|
|
117
|
+
{ key: 'taxRates', label: 'Tax rates list (display_name / percentage / inclusive)', status: 'rendered' },
|
|
118
|
+
{ key: 'taxCalculations', label: 'Tax calculations list (amount_total + tax amount)', status: 'rendered' },
|
|
119
|
+
{ key: 'taxCalculationLineItems', label: 'Tax calculation line items (nested, per-item tax)', status: 'rendered' },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/** Read-only completeness check over the declared Stripe UI inventory. */
|
|
123
|
+
export function stripeUiConformance(): UiCompletenessReport {
|
|
124
|
+
return checkUiCompleteness('stripe', STRIPE_UI_INVENTORY);
|
|
125
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// Stripe MIRROR — structural DOM checklist (RUNG-5).
|
|
2
|
+
//
|
|
3
|
+
// Completeness (stripe-ui-conformance.ts) asks "does the mirror SHOW the data?".
|
|
4
|
+
// Structure asks a stricter, proximity question: "does the mirror's rendered DOM have
|
|
5
|
+
// the same structural LANDMARKS a real Stripe dashboard screen has?" — a left nav with
|
|
6
|
+
// an item (and count) per collection, a list pane that emits a row per object, a detail
|
|
7
|
+
// pane with a scalar key/value grid, nested objects rendered as indented lines (NOT
|
|
8
|
+
// "[object Object]"), status pills, a cross-reference "Related" block of links,
|
|
9
|
+
// product image <img> thumbnails, a prominent payment-decline banner (last_payment_error)
|
|
10
|
+
// and the synthesized balance available/pending summary buckets.
|
|
11
|
+
//
|
|
12
|
+
// Each check's `present` is computed from the ACTUAL markup produced by
|
|
13
|
+
// renderToStaticMarkup() of the mirror's own presentational components (exported from
|
|
14
|
+
// client/stripe-mirror.tsx) over seeded, cross-referenced objects — so a check cannot
|
|
15
|
+
// pass unless the component truly emits that structure. Read-only; deterministic.
|
|
16
|
+
import { createElement } from 'react';
|
|
17
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
18
|
+
import { checkUiStructure, type UiStructureReport } from '@volter/twin-tooling';
|
|
19
|
+
import {
|
|
20
|
+
SECTIONS, SideNav, ListPane, Detail, type Section,
|
|
21
|
+
} from '../client/stripe-mirror.tsx';
|
|
22
|
+
import {
|
|
23
|
+
resolveCrossRefs, productImageUrls,
|
|
24
|
+
type StripeRow, type CrossRefs,
|
|
25
|
+
} from './stripe-mirror-ui.ts';
|
|
26
|
+
|
|
27
|
+
const SECTION_BY_KEY: Record<string, Section> = Object.fromEntries(SECTIONS.map((s) => [s.key, s]));
|
|
28
|
+
|
|
29
|
+
// ── Seed: a small, cross-referenced slice of every shape a dashboard renders. ──────
|
|
30
|
+
// One object per collection we assert on, wired with real Stripe reference fields so
|
|
31
|
+
// the cross-ref resolver produces both outgoing and incoming "Related" links, and with
|
|
32
|
+
// nested objects/arrays (subscription items, invoice lines, product images) so the
|
|
33
|
+
// nested-line + thumbnail structure has something real to render.
|
|
34
|
+
const CUSTOMER: StripeRow = {
|
|
35
|
+
id: 'cus_twin001', object: 'customer', name: 'Ada Lovelace', email: 'ada@example.com', currency: 'usd',
|
|
36
|
+
metadata: { plan: 'pro', seats: '5' },
|
|
37
|
+
};
|
|
38
|
+
const PRODUCT: StripeRow = {
|
|
39
|
+
id: 'prod_twin001', object: 'product', name: 'Analytics Suite', active: true,
|
|
40
|
+
images: ['https://files.stripe.com/img/one.png', 'https://files.stripe.com/img/two.png'],
|
|
41
|
+
};
|
|
42
|
+
const PRICE: StripeRow = {
|
|
43
|
+
id: 'price_twin001', object: 'price', product: 'prod_twin001', currency: 'usd', unit_amount: 4200,
|
|
44
|
+
recurring: { interval: 'month', interval_count: 1 },
|
|
45
|
+
};
|
|
46
|
+
const SUBSCRIPTION: StripeRow = {
|
|
47
|
+
id: 'sub_twin001', object: 'subscription', status: 'active', customer: 'cus_twin001', currency: 'usd',
|
|
48
|
+
items: {
|
|
49
|
+
object: 'list',
|
|
50
|
+
data: [
|
|
51
|
+
{ id: 'si_twin001', object: 'subscription_item', quantity: 5, price: 'price_twin001' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const INVOICE: StripeRow = {
|
|
56
|
+
id: 'in_twin001', object: 'invoice', status: 'paid', customer: 'cus_twin001', subscription: 'sub_twin001',
|
|
57
|
+
currency: 'usd', total: 21000, amount_due: 21000,
|
|
58
|
+
lines: {
|
|
59
|
+
object: 'list',
|
|
60
|
+
data: [
|
|
61
|
+
{ id: 'il_twin001', object: 'line_item', amount: 21000, quantity: 5, price: 'price_twin001' },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const DISPUTE: StripeRow = {
|
|
66
|
+
id: 'dp_twin001', object: 'dispute', amount: 4200, currency: 'usd', reason: 'fraudulent',
|
|
67
|
+
status: 'warning_needs_response', charge: 'ch_twin001',
|
|
68
|
+
};
|
|
69
|
+
const PAYOUT: StripeRow = {
|
|
70
|
+
id: 'po_twin001', object: 'payout', amount: 16800, currency: 'usd', status: 'in_transit',
|
|
71
|
+
arrival_date: 1718409600,
|
|
72
|
+
};
|
|
73
|
+
// A declined payment_intent carries the vendor-faithful last_payment_error (test-card
|
|
74
|
+
// decline state) the mirror lifts into a prominent banner.
|
|
75
|
+
const DECLINED_PAYMENT: StripeRow = {
|
|
76
|
+
id: 'pi_twin009', object: 'payment_intent', amount: 4200, currency: 'usd', customer: 'cus_twin001',
|
|
77
|
+
status: 'requires_payment_method',
|
|
78
|
+
last_payment_error: { type: 'card_error', code: 'card_declined', decline_code: 'insufficient_funds', message: 'Your card has insufficient funds.', param: 'card' },
|
|
79
|
+
};
|
|
80
|
+
// The synthesized account balance summary (GET /v1/balance), presented as a one-row list.
|
|
81
|
+
const BALANCE: StripeRow = {
|
|
82
|
+
id: 'balance', object: 'balance', livemode: false,
|
|
83
|
+
available: [{ amount: 16800, currency: 'usd', source_types: { card: 16800 } }],
|
|
84
|
+
pending: [{ amount: 4200, currency: 'usd', source_types: { card: 4200 } }],
|
|
85
|
+
};
|
|
86
|
+
// A Connect connected account, freshly created (not yet onboarded): charges/payouts
|
|
87
|
+
// disabled, details not submitted, with outstanding `requirements.currently_due`.
|
|
88
|
+
const ACCOUNT: StripeRow = {
|
|
89
|
+
id: 'acct_twin001', object: 'account', type: 'express', country: 'US', email: 'seller@example.com',
|
|
90
|
+
charges_enabled: false, payouts_enabled: false, details_submitted: false,
|
|
91
|
+
capabilities: { card_payments: 'inactive' },
|
|
92
|
+
requirements: { currently_due: ['external_account', 'tos_acceptance.date'], eventually_due: [], past_due: [], pending_verification: [] },
|
|
93
|
+
};
|
|
94
|
+
// A transfer from the platform balance to the connected account above (destination cross-ref).
|
|
95
|
+
const TRANSFER: StripeRow = {
|
|
96
|
+
id: 'tr_twin001', object: 'transfer', amount: 16800, currency: 'usd', destination: 'acct_twin001',
|
|
97
|
+
reversed: false, amount_reversed: 0,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// A Stripe Tax rate (reusable) and a tax calculation with priced line_items (nested).
|
|
101
|
+
const TAX_RATE: StripeRow = {
|
|
102
|
+
id: 'txr_twin001', object: 'tax_rate', display_name: 'Sales Tax', percentage: 8.5,
|
|
103
|
+
inclusive: false, active: true, jurisdiction: 'US',
|
|
104
|
+
};
|
|
105
|
+
const TAX_CALCULATION: StripeRow = {
|
|
106
|
+
id: 'taxcalc_twin001', object: 'tax.calculation', currency: 'usd', amount_total: 1100,
|
|
107
|
+
tax_amount_exclusive: 100, customer: 'cus_twin001',
|
|
108
|
+
line_items: {
|
|
109
|
+
object: 'list',
|
|
110
|
+
data: [
|
|
111
|
+
{ id: 'tax_li_taxcalc_twin001_1', object: 'tax.calculation_line_item', amount: 1000, amount_tax: 100, reference: 'sku_1', tax_behavior: 'exclusive' },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// A credit note crediting back a paid invoice, with a nested credit_note_line_item, and
|
|
117
|
+
// cross-refs to its invoice + customer. The mirror's Credit Notes section renders it.
|
|
118
|
+
const CREDIT_NOTE: StripeRow = {
|
|
119
|
+
id: 'cn_twin001', object: 'credit_note', amount: 5000, currency: 'usd', status: 'issued',
|
|
120
|
+
invoice: 'in_twin001', customer: 'cus_twin001', type: 'post_payment', number: 'in_twin001-CN-01',
|
|
121
|
+
lines: {
|
|
122
|
+
object: 'list',
|
|
123
|
+
data: [
|
|
124
|
+
{ id: 'cnli_cn_twin001_1', object: 'credit_note_line_item', type: 'custom_line_item', amount: 5000, description: 'goodwill credit' },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
// A customer tax ID (EU VAT) on the customer above (customer cross-ref).
|
|
129
|
+
const TAX_ID: StripeRow = {
|
|
130
|
+
id: 'txi_twin001', object: 'tax_id', type: 'eu_vat', value: 'DE123456789',
|
|
131
|
+
customer: 'cus_twin001', country: 'DE', verification: { status: 'pending' },
|
|
132
|
+
};
|
|
133
|
+
// A customer balance transaction (a -2000 credit) with its running ending_balance.
|
|
134
|
+
const CUSTOMER_BALANCE_TXN: StripeRow = {
|
|
135
|
+
id: 'cbtxn_twin001', object: 'customer_balance_transaction', amount: -2000, currency: 'usd',
|
|
136
|
+
ending_balance: -2000, type: 'adjustment', customer: 'cus_twin001', credit_note: 'cn_twin001',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const DATA: Record<string, StripeRow[]> = {
|
|
140
|
+
customers: [CUSTOMER],
|
|
141
|
+
credit_notes: [CREDIT_NOTE],
|
|
142
|
+
tax_ids: [TAX_ID],
|
|
143
|
+
customer_balance_transactions: [CUSTOMER_BALANCE_TXN],
|
|
144
|
+
tax_rates: [TAX_RATE],
|
|
145
|
+
'tax/calculations': [TAX_CALCULATION],
|
|
146
|
+
products: [PRODUCT],
|
|
147
|
+
prices: [PRICE],
|
|
148
|
+
subscriptions: [SUBSCRIPTION],
|
|
149
|
+
invoices: [INVOICE],
|
|
150
|
+
disputes: [DISPUTE],
|
|
151
|
+
payouts: [PAYOUT],
|
|
152
|
+
payment_intents: [DECLINED_PAYMENT],
|
|
153
|
+
balance: [BALANCE],
|
|
154
|
+
accounts: [ACCOUNT],
|
|
155
|
+
transfers: [TRANSFER],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const noop = () => {};
|
|
159
|
+
|
|
160
|
+
/** renderToStaticMarkup the left nav (one item + count per collection). */
|
|
161
|
+
function renderNav(): string {
|
|
162
|
+
const counts = Object.fromEntries(SECTIONS.map((s) => [s.key, DATA[s.key]?.length ?? 0]));
|
|
163
|
+
return renderToStaticMarkup(
|
|
164
|
+
createElement(SideNav, { sections: SECTIONS, counts, activeKey: SECTIONS[0]!.key, onSelect: noop }),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** renderToStaticMarkup the list pane for a collection (a row per object). */
|
|
169
|
+
function renderList(collection: string, rows: StripeRow[]): string {
|
|
170
|
+
return renderToStaticMarkup(
|
|
171
|
+
createElement(ListPane, { section: SECTION_BY_KEY[collection]!, rows, currentId: rows[0]?.id, onSelect: noop }),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** renderToStaticMarkup the detail pane for a row, with its resolved cross-refs. */
|
|
176
|
+
function renderDetail(collection: string, row: StripeRow): string {
|
|
177
|
+
const refs: CrossRefs = resolveCrossRefs(collection, row, DATA);
|
|
178
|
+
return renderToStaticMarkup(
|
|
179
|
+
createElement(Detail, { collection, row, refs, onJump: noop }),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build the structural checklist from the real rendered markup and run it.
|
|
185
|
+
* ~10 checks, every one asserting a Stripe-dashboard structural landmark.
|
|
186
|
+
*/
|
|
187
|
+
export function stripeUiStructure(): UiStructureReport {
|
|
188
|
+
const nav = renderNav();
|
|
189
|
+
const customersList = renderList('customers', DATA.customers!);
|
|
190
|
+
const subscriptionDetail = renderDetail('subscriptions', SUBSCRIPTION);
|
|
191
|
+
const invoiceDetail = renderDetail('invoices', INVOICE);
|
|
192
|
+
const productDetail = renderDetail('products', PRODUCT);
|
|
193
|
+
const customerDetail = renderDetail('customers', CUSTOMER);
|
|
194
|
+
const declinedPaymentDetail = renderDetail('payment_intents', DECLINED_PAYMENT);
|
|
195
|
+
const balanceDetail = renderDetail('balance', BALANCE);
|
|
196
|
+
const accountDetail = renderDetail('accounts', ACCOUNT);
|
|
197
|
+
const transferDetail = renderDetail('transfers', TRANSFER);
|
|
198
|
+
const taxRatesList = renderList('tax_rates', DATA.tax_rates!);
|
|
199
|
+
const taxCalculationDetail = renderDetail('tax/calculations', TAX_CALCULATION);
|
|
200
|
+
const creditNotesList = renderList('credit_notes', DATA.credit_notes!);
|
|
201
|
+
const creditNoteDetail = renderDetail('credit_notes', CREDIT_NOTE);
|
|
202
|
+
const taxIdsList = renderList('tax_ids', DATA.tax_ids!);
|
|
203
|
+
const customerBalanceTxnDetail = renderDetail('customer_balance_transactions', CUSTOMER_BALANCE_TXN);
|
|
204
|
+
|
|
205
|
+
// How many collection nav items the markup emits. The left nav now also renders the Home
|
|
206
|
+
// and Settings chrome buttons (which carry no per-collection count), so the per-collection
|
|
207
|
+
// landmark counts the items that carry a nav-count — exactly one per collection.
|
|
208
|
+
const navItems = (nav.match(/nav-count/g) ?? []).length;
|
|
209
|
+
const customerRows = (customersList.match(/list-row/g) ?? []).length;
|
|
210
|
+
|
|
211
|
+
const checks = [
|
|
212
|
+
{
|
|
213
|
+
key: 'navItemPerCollection',
|
|
214
|
+
label: 'Left nav emits a nav item per collection (Customers/Payments/Subscriptions/Invoices/Products/…)',
|
|
215
|
+
present: navItems === SECTIONS.length && nav.includes('class="side"'),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
key: 'navCounts',
|
|
219
|
+
label: 'Each left-nav item carries a per-collection object count',
|
|
220
|
+
present: (nav.match(/nav-count/g) ?? []).length === SECTIONS.length,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
key: 'listRowPerObject',
|
|
224
|
+
label: 'List pane emits one row per object in the section',
|
|
225
|
+
present: customerRows === DATA.customers!.length && customersList.includes('class="list"'),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
key: 'detailScalarGrid',
|
|
229
|
+
label: 'Detail pane renders a scalar field key/value grid (<dl>/<dt>/<dd>)',
|
|
230
|
+
present: customerDetail.includes('<dl>') && customerDetail.includes('<dt>') && customerDetail.includes('<dd'),
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
key: 'detailHeaderId',
|
|
234
|
+
label: 'Detail pane shows the object id in its header',
|
|
235
|
+
present: customerDetail.includes('detail-head') && customerDetail.includes(CUSTOMER.id),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
key: 'nestedLinesNotObjectObject',
|
|
239
|
+
label: 'Nested objects/arrays render as indented nested lines, never "[object Object]"',
|
|
240
|
+
present:
|
|
241
|
+
subscriptionDetail.includes('class="nested"') &&
|
|
242
|
+
subscriptionDetail.includes('nested-line') &&
|
|
243
|
+
!subscriptionDetail.includes('[object Object]') &&
|
|
244
|
+
!invoiceDetail.includes('[object Object]'),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
key: 'nestedListItems',
|
|
248
|
+
label: 'Nested Stripe list wrappers (subscription items / invoice lines) flatten to their data rows',
|
|
249
|
+
present:
|
|
250
|
+
subscriptionDetail.includes('si_twin001') &&
|
|
251
|
+
invoiceDetail.includes('il_twin001'),
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
key: 'statusPill',
|
|
255
|
+
label: 'A status pill element renders for objects with a status (active/paid/…)',
|
|
256
|
+
present: subscriptionDetail.includes('class="pill') && invoiceDetail.includes('class="pill'),
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
key: 'relatedCrossRefBlock',
|
|
260
|
+
label: 'A cross-ref "Related" block renders outgoing reference links',
|
|
261
|
+
present:
|
|
262
|
+
invoiceDetail.includes('detail-block refs') &&
|
|
263
|
+
invoiceDetail.includes('>Related<') &&
|
|
264
|
+
invoiceDetail.includes('>References<') &&
|
|
265
|
+
(invoiceDetail.match(/ref-chip/g) ?? []).length >= 1,
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
key: 'relatedIncomingRefs',
|
|
269
|
+
label: 'The "Related" block renders incoming "Referenced by" links (objects pointing at this row)',
|
|
270
|
+
// The customer is pointed AT by the subscription + invoice → incoming chips.
|
|
271
|
+
present:
|
|
272
|
+
customerDetail.includes('detail-block refs') &&
|
|
273
|
+
customerDetail.includes('>Referenced by<') &&
|
|
274
|
+
(customerDetail.match(/ref-chip/g) ?? []).length >= 1,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
key: 'crossRefResolvesTargetLabel',
|
|
278
|
+
label: 'Cross-ref links resolve the target object label (not just a bare id)',
|
|
279
|
+
// invoice → customer outgoing chip carries the resolved customer name.
|
|
280
|
+
present: invoiceDetail.includes('Ada Lovelace'),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
key: 'productImageThumbs',
|
|
284
|
+
label: 'Product images render as <img> thumbnails',
|
|
285
|
+
present:
|
|
286
|
+
productDetail.includes('class="thumbs"') &&
|
|
287
|
+
(productDetail.match(/<img/g) ?? []).length === productImageUrls(PRODUCT.images).length &&
|
|
288
|
+
productImageUrls(PRODUCT.images).length > 0,
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
key: 'paymentDeclineBanner',
|
|
292
|
+
label: 'A declined payment_intent renders its last_payment_error as a prominent banner (decline reason + code)',
|
|
293
|
+
present:
|
|
294
|
+
declinedPaymentDetail.includes('class="pay-error"') &&
|
|
295
|
+
declinedPaymentDetail.includes('Payment error') &&
|
|
296
|
+
declinedPaymentDetail.includes('card_declined') &&
|
|
297
|
+
declinedPaymentDetail.includes('insufficient_funds') &&
|
|
298
|
+
// lifted out of the generic grid, not buried as a raw nested object
|
|
299
|
+
!declinedPaymentDetail.includes('[object Object]'),
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
key: 'balanceSummaryBuckets',
|
|
303
|
+
label: 'The balance section renders available/pending summary buckets with formatted amounts',
|
|
304
|
+
present:
|
|
305
|
+
balanceDetail.includes('class="balance-summary"') &&
|
|
306
|
+
balanceDetail.includes('balance-bucket available') &&
|
|
307
|
+
(balanceDetail.match(/balance-bucket/g) ?? []).length >= 2 &&
|
|
308
|
+
balanceDetail.includes('$168.00') &&
|
|
309
|
+
balanceDetail.includes('$42.00'),
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
key: 'connectAccountFlags',
|
|
313
|
+
label: 'A connected account renders its enablement flags (charges/payouts/details_submitted) as tone-carrying tiles',
|
|
314
|
+
present:
|
|
315
|
+
accountDetail.includes('class="connect-panel"') &&
|
|
316
|
+
(accountDetail.match(/connect-flag /g) ?? []).length >= 3 &&
|
|
317
|
+
accountDetail.includes('>Charges<') &&
|
|
318
|
+
accountDetail.includes('>Payouts<') &&
|
|
319
|
+
accountDetail.includes('>Details submitted<') &&
|
|
320
|
+
// a freshly-created account is not enabled → disabled state shown
|
|
321
|
+
accountDetail.includes('>disabled<'),
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
key: 'connectAccountRequirements',
|
|
325
|
+
label: 'A connected account renders its outstanding onboarding requirements (currently_due)',
|
|
326
|
+
present:
|
|
327
|
+
accountDetail.includes('class="connect-requirements"') &&
|
|
328
|
+
accountDetail.includes('Requirements currently due') &&
|
|
329
|
+
accountDetail.includes('external_account') &&
|
|
330
|
+
accountDetail.includes('tos_acceptance.date'),
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
key: 'transferDestinationCrossRef',
|
|
334
|
+
label: 'A transfer renders a cross-ref link to its destination connected account',
|
|
335
|
+
present:
|
|
336
|
+
transferDetail.includes('detail-block refs') &&
|
|
337
|
+
(transferDetail.match(/ref-chip/g) ?? []).length >= 1 &&
|
|
338
|
+
transferDetail.includes('acct_twin001'),
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
key: 'taxRatesListRow',
|
|
342
|
+
label: 'The Tax Rates section emits a list row per rate (display_name + percentage)',
|
|
343
|
+
present:
|
|
344
|
+
(taxRatesList.match(/list-row/g) ?? []).length === DATA.tax_rates!.length &&
|
|
345
|
+
taxRatesList.includes('Sales Tax') &&
|
|
346
|
+
taxRatesList.includes('8.5'),
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
key: 'taxCalculationLineItems',
|
|
350
|
+
label: 'A tax calculation renders its priced line_items as nested lines (per-item tax)',
|
|
351
|
+
present:
|
|
352
|
+
taxCalculationDetail.includes('class="nested"') &&
|
|
353
|
+
taxCalculationDetail.includes('tax_li_taxcalc_twin001_1') &&
|
|
354
|
+
!taxCalculationDetail.includes('[object Object]'),
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
key: 'taxCalculationTotals',
|
|
358
|
+
label: 'A tax calculation renders its amount_total and tax_amount_exclusive (currency-formatted)',
|
|
359
|
+
present:
|
|
360
|
+
taxCalculationDetail.includes('$11.00') &&
|
|
361
|
+
taxCalculationDetail.includes('$1.00'),
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
key: 'creditNotesListRow',
|
|
365
|
+
label: 'The Credit Notes section emits a list row per credit note (credited amount + status)',
|
|
366
|
+
present:
|
|
367
|
+
(creditNotesList.match(/list-row/g) ?? []).length === DATA.credit_notes!.length &&
|
|
368
|
+
creditNotesList.includes('$50.00') &&
|
|
369
|
+
creditNotesList.includes('issued'),
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
key: 'creditNoteLineItems',
|
|
373
|
+
label: 'A credit note renders its nested line_items (per-line credited amount), never "[object Object]"',
|
|
374
|
+
present:
|
|
375
|
+
creditNoteDetail.includes('class="nested"') &&
|
|
376
|
+
creditNoteDetail.includes('cnli_cn_twin001_1') &&
|
|
377
|
+
!creditNoteDetail.includes('[object Object]'),
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
key: 'creditNoteInvoiceCrossRef',
|
|
381
|
+
label: 'A credit note renders a cross-ref link to the invoice it credits',
|
|
382
|
+
present:
|
|
383
|
+
creditNoteDetail.includes('detail-block refs') &&
|
|
384
|
+
(creditNoteDetail.match(/ref-chip/g) ?? []).length >= 1 &&
|
|
385
|
+
creditNoteDetail.includes('in_twin001'),
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
key: 'customerTaxIdsListRow',
|
|
389
|
+
label: 'The Customer Tax IDs section emits a list row per tax ID (value + type)',
|
|
390
|
+
present:
|
|
391
|
+
(taxIdsList.match(/list-row/g) ?? []).length === DATA.tax_ids!.length &&
|
|
392
|
+
taxIdsList.includes('DE123456789') &&
|
|
393
|
+
taxIdsList.includes('eu_vat'),
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
key: 'customerBalanceTxnDetail',
|
|
397
|
+
label: 'A customer balance transaction renders its amount + running ending_balance (formatted) with a customer cross-ref',
|
|
398
|
+
present:
|
|
399
|
+
customerBalanceTxnDetail.includes('detail-block refs') &&
|
|
400
|
+
customerBalanceTxnDetail.includes('-$20.00') &&
|
|
401
|
+
(customerBalanceTxnDetail.match(/ref-chip/g) ?? []).length >= 1,
|
|
402
|
+
},
|
|
403
|
+
];
|
|
404
|
+
|
|
405
|
+
return checkUiStructure('stripe', checks);
|
|
406
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_doc": "Declared scope for the Stripe twin: published fields it does NOT emit, each with a reason. The spec-conformance gate (stripe-conformance.ts) treats a missing-required field as a BUG unless it is declared here. Applies across the Stripe objects the twin serves. Stripe is the twin we cannot dual-run live, so the published OpenAPI is the authority \u2014 keeping this list short and reasoned is how we stay honest about the gaps.",
|
|
3
|
+
"deviations": [
|
|
4
|
+
{
|
|
5
|
+
"path": "type",
|
|
6
|
+
"kind": "missing-required",
|
|
7
|
+
"reason": "Stripe's price.type (one_time|recurring) collides with the kernel's resource-type discriminator (every twin resource carries an internal `type`, which view() strips before emit). So a vendor `type` field cannot currently be emitted. Known limitation \u2014 a candidate for moving the kernel discriminator to a reserved key. Only `price` declares a required `type`; other served objects do not."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"path": "_price.type-list-filter",
|
|
11
|
+
"kind": "list-filter-approximated",
|
|
12
|
+
"reason": "GET /v1/prices?type=one_time|recurring cannot filter on the (stripped) price.type field for the same reason price.type is a declared missing-required deviation above. It is approximated by the presence of the vendor `recurring` object \u2014 recurring prices carry it, one-time prices do not \u2014 which is faithful for prices the twin itself created. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "_invoice.subscription-list-filter",
|
|
16
|
+
"kind": "list-filter-unmodeled",
|
|
17
|
+
"reason": "GET /v1/invoices accepts a `subscription` filter param (Stripe documents it) but the twin does not auto-link invoices to subscriptions: invoices are created as standalone drafts, and modern Stripe nests the link under parent.subscription_details.subscription rather than a top-level field (the vendor schema has no top-level invoice.subscription). The filter therefore only matches when a caller has explicitly stored a `subscription` field on the invoice; otherwise it yields no matches. The param is accepted, not rejected \u2014 consistent with Stripe ignoring filters that match nothing. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "_dispute.not-auto-created",
|
|
21
|
+
"kind": "resource-modeled-statefully",
|
|
22
|
+
"reason": "Disputes are modeled statefully via the action log (create/list/retrieve/update/close), NOT auto-raised from charges: the twin does not simulate an acquirer/cardholder raising a chargeback, so no dispute appears merely because a charge exists. A caller (or seeding) creates one referencing a charge via POST /v1/disputes. All 15 vendor-required dispute fields ARE emitted (amount, balance_transactions, charge, created, currency, enhanced_eligibility_types, evidence, evidence_details, is_charge_refundable, livemode, metadata, reason, status, plus id/object); `amount`/`charge` come from the caller (Stripe disputes always reference a charge). Closing is terminal (status -> lost), matching Stripe. evidence/evidence_details emit Stripe's canonical empty shapes (all-null evidence bag), not fabricated content. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "_payout.created-not-settled",
|
|
26
|
+
"kind": "resource-modeled-statefully",
|
|
27
|
+
"reason": "Payouts are modeled statefully via the action log (create/list/retrieve/cancel). The twin does not simulate the bank-settlement timeline: a created payout starts `status=pending` and only transitions on an explicit POST /v1/payouts/:id/cancel (-> canceled); it does not auto-advance to in_transit/paid. arrival_date is stamped at creation time rather than a real future settlement date. All vendor-required payout fields are emitted; `amount`/`currency` come from the caller and are validated (positive integer + currency) like charges. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"path": "_balance.synthesized-from-ledger",
|
|
31
|
+
"kind": "resource-synthesized",
|
|
32
|
+
"reason": "GET /v1/balance is synthesized from the balance_transaction ledger, not stored: settled transactions (status=available) fold into the `available` array and unsettled ones (status=pending) into `pending`, summed by currency over each transaction's `net` (amount minus fee). This mirrors Stripe's available/pending arrays per currency. The per-currency source_types breakdown is approximated as all-`card`. With no ledger the balance is a valid zero (one usd entry). Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"path": "_balance_transaction.writable-for-seeding",
|
|
36
|
+
"kind": "resource-modeled-statefully",
|
|
37
|
+
"reason": "In real Stripe, balance_transactions are created only as side-effects of money movement (charges, refunds, payouts) and have no create endpoint. The twin exposes POST /v1/balance_transactions so the ledger can be seeded statefully, since the twin does not auto-generate a balance_transaction for every charge/refund/payout. `net` defaults to amount - fee. All vendor-required fields are emitted with faithful enum values (type/status/balance_type/reporting_category). Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"path": "_card.declines-test-set-only",
|
|
41
|
+
"kind": "behavior-modeled-subset",
|
|
42
|
+
"reason": "Charge/PaymentIntent-confirm card declines are modeled for Stripe's DOCUMENTED TEST CARDS only (PAN 4242\u20264242 + pm_card_visa/tok_visa succeed; 4000\u20260002 generic_decline, 4000\u20269995 insufficient_funds, 4000\u20260069 expired_card, 4000\u20260127 incorrect_cvc, 4000\u20260119 processing_error, plus the matching pm_card_*/tok_* tokens). A known declining card returns the real Stripe card-error envelope (HTTP 402, error.type=card_error, code, decline_code for card_declined, message, param=card, and the charge/payment_intent id) and leaves a confirmed PaymentIntent at status=requires_payment_method with last_payment_error set; a declined charge is not persisted. ANY card the twin does not recognize (real PANs, unknown test tokens, or no card at all) succeeds, deterministically \u2014 the twin is not a risk/fraud engine and does not simulate issuer behavior beyond Stripe's published test set. Resolution reads card[number]/source[number] (raw PAN) or payment_method/source/card (token id). Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"path": "_idempotency.stored-per-root",
|
|
46
|
+
"kind": "behavior-modeled-statefully",
|
|
47
|
+
"reason": "The Idempotency-Key request header is honored on POST: the first request with a key executes and its (status + body) response is persisted in the action log as an internal `_idempotency` resource; a replay with the SAME key returns the byte-identical stored response without re-applying the write (verified: two POSTs \u2192 one resource). Simplifications vs real Stripe: the stored response is keyed per twin-root (a fork has its own keyspace) rather than per Stripe account+key; there is no 24h expiry; and the twin does not detect a request-payload mismatch for a reused key (real Stripe 400s when the same key is reused with different params). Every executed POST is stored, including card-decline 402s (Stripe also persists those). `_idempotency` is twin-internal and never served as a Stripe object, so it is exempt from spec conformance. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "_event.modeled-shape",
|
|
51
|
+
"kind": "resource-modeled-statefully",
|
|
52
|
+
"reason": "The Events API (GET /v1/events, GET /v1/events/:id) is modeled statefully via the action log. The twin separately emits live webhook events on writes (stripe-events.ts); the Events API here serves explicitly-recorded event objects with the faithful vendor shape (type, api_version, created, data.object, livemode, pending_webhooks). pending_webhooks defaults to 0 because the twin delivers webhooks synchronously. data.object defaults to an empty object when the caller does not supply a snapshot. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "_checkout.hosted-page-pixels",
|
|
56
|
+
"kind": "non-goal-render-omitted",
|
|
57
|
+
"reason": "Checkout Sessions (POST/GET /v1/checkout/sessions, GET :id/line_items, POST :id/expire) and Customer Portal sessions (POST /v1/billing_portal/sessions, + billing_portal/configurations) are modeled as API OBJECTS with vendor-faithful shapes, ids (cs_/bps_/bpc_), status/payment_status enums, list envelope, and 400/404 errors. Rendering the Stripe-HOSTED Checkout / Customer Portal PAGE PIXELS is declared out of scope: the hosted HTML is Stripe's, not an API object. The twin models the Session object + redirect `url` (https://checkout.twin.local/... and https://billing.twin.local/...), which is the surface the unmodified `stripe` SDK and apps depend on. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"path": "_checkout.completion-modeled",
|
|
61
|
+
"kind": "behavior-modeled-statefully",
|
|
62
|
+
"reason": "Real Stripe completes a Checkout Session when the customer pays on the hosted page (there is no public REST verb to complete a session). The twin models that terminal transition via POST /v1/checkout/sessions/:id with status=complete (only valid from `open`), which faithfully creates+links a real twin object: a succeeded payment_intent (mode=payment, amount = amount_total) with payment_status\u2192paid, an active subscription (mode=subscription, when a customer is present) with payment_status\u2192paid, or a succeeded setup_intent (mode=setup). POST :id/expire transitions open\u2192expired (terminal). amount_subtotal/amount_total are computed by summing resolved line_items (an existing Price's unit_amount, or inline price_data.unit_amount, \u00d7 quantity). Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"path": "_tax.calculation-list-endpoint",
|
|
66
|
+
"kind": "endpoint-added-for-mirror",
|
|
67
|
+
"reason": "Real Stripe has NO list endpoint for tax calculations (POST creates a calculation, GET :id retrieves it, GET :id/line_items lists its items). The twin adds GET /v1/tax/calculations as a faithful-shaped list envelope (object:'list', data[]) purely so the dashboard mirror's Tax > Calculations section can render the calculations stored in twin state. Create/retrieve/line_items match Stripe exactly; the list is a twin convenience. The twin also models a DEFAULT 10% exclusive tax rate for calculations (real Stripe derives the rate from the customer's jurisdiction + registrations) so the returned amount_total/tax_amount_exclusive are deterministic and assertable. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"path": "_tax_id.flat-list-endpoint",
|
|
71
|
+
"kind": "list-endpoint-added",
|
|
72
|
+
"reason": "Real Stripe scopes tax IDs UNDER a customer (POST/GET /v1/customers/:id/tax_ids). The twin also exposes a flat GET /v1/tax_ids list (filterable by customer) so the dashboard mirror can render a Customer Tax IDs section; the canonical per-customer create/retrieve/list/delete are the faithful surface and all enforce 404 on a missing customer/tax_id. Deleted tax IDs (DELETE returns the deleted-object stub) are excluded from both lists. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"path": "_customer_balance_transaction.flat-list-endpoint",
|
|
76
|
+
"kind": "list-endpoint-added",
|
|
77
|
+
"reason": "Real Stripe scopes customer balance transactions UNDER a customer (POST/GET /v1/customers/:id/balance_transactions). The twin also exposes a flat GET /v1/customer_balance_transactions list so the mirror can render a Customer Balance section. ending_balance is the running balance after each txn (a negative amount is a credit), computed from the prior ledger, and the owning customer.balance is kept in sync. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"path": "_credit_note.single-amount-type",
|
|
81
|
+
"kind": "field-simplified",
|
|
82
|
+
"reason": "CreditNotes are modeled in the amount form (the credited cents) referencing a finalized invoice (POST /v1/credit_notes requires invoice (must exist) plus a positive amount); preview (GET /v1/credit_notes/preview), retrieve, /lines, void (terminal: status void) and list all round-trip. Stripe additionally supports per-invoice-line credits and a mixed type; the twin emits a single custom_line_item and picks pre_payment (unpaid invoice) or post_payment (paid invoice) for the vendor type. All 22 vendor-required credit_note fields are emitted. The vendor type field collides with the kernel discriminator and is stashed under the reserved _stripe_type key (restored by view()), like payout/account. Leading underscore marks this as a twin-internal note (not a vendor schema path)."
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|