@itm-studio/partner-sdk 0.3.5 → 0.3.7

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/README.md CHANGED
@@ -47,6 +47,11 @@ const { getPartnerMomentsForBrand } = await itm.query({
47
47
  city: true,
48
48
  country: true,
49
49
  },
50
+ publicVenuePreview: {
51
+ city: true,
52
+ country: true,
53
+ countryCode: true,
54
+ },
50
55
  ticketTiers: {
51
56
  name: true,
52
57
  price: true,
@@ -515,6 +520,114 @@ The response is always scoped by the partner token; do not pass a brand ID.
515
520
  `coverImageMobile` and `musicLink` can be `null`, so handle those as optional in
516
521
  rendering code.
517
522
 
523
+ ### `getPartnerContributionPages`
524
+
525
+ Get paginated contribution (tip/donation) pages for the brand associated with
526
+ your partner token, newest first. Each page carries its hosted checkout URL
527
+ (`publicUrl`) and confirmed-money totals, so a partner site can link straight
528
+ to checkout and show progress without computing anything itself.
529
+
530
+ Like every contribution operation, this query is guarded for the brand partner
531
+ token (`BrandPartnerGuard`): call it from a `token` client. Brand Agent
532
+ `agentSession` and `agentAccess` clients are not authorized for it and fail
533
+ closed, and the brand is always derived from the token — never pass a brand ID.
534
+
535
+ Supported filters:
536
+
537
+ - `status`: `ACCEPTING`, `PAUSED`, or `ARCHIVED`; omit for all pages.
538
+ - `search`: case-insensitive match over title and slug.
539
+ - `cursor` and `take` (1–100, default 25): use `nextCursor` while `hasNextPage` is true.
540
+
541
+ ```typescript
542
+ const { getPartnerContributionPages } = await itm.query({
543
+ getPartnerContributionPages: {
544
+ __args: { status: "ACCEPTING", take: 20 },
545
+ items: {
546
+ uid: true,
547
+ slug: true,
548
+ title: true,
549
+ description: true,
550
+ publicUrl: true, // hosted checkout page on ITM
551
+ qrCodeImageUrl: true, // data URL that resolves to publicUrl
552
+ canContribute: true,
553
+ currency: { code: true, symbol: true },
554
+ suggestedAmountsCents: true,
555
+ recommendedAmountCents: true,
556
+ successfulContributionCount: true,
557
+ grossAmountCents: true,
558
+ netAmountCents: true,
559
+ },
560
+ totalCount: true,
561
+ hasNextPage: true,
562
+ nextCursor: true,
563
+ },
564
+ });
565
+ ```
566
+
567
+ `grossAmountCents`, `refundedAmountCents`, and `netAmountCents` are strings
568
+ because totals can exceed a GraphQL `Int`. `canContribute` is `false` while a
569
+ page is paused, archived, or its payout account is not verified; render the
570
+ link only when it is `true`.
571
+
572
+ ### `getPartnerContributions`
573
+
574
+ Get paginated contributions for the brand associated with your partner token,
575
+ newest first. This is the feed for a live contributor wall or rolling counter:
576
+ filter `status: "SUCCEEDED"` to list only confirmed contributors, and read
577
+ `note` for the optional message they left at checkout.
578
+
579
+ This query runs behind the same brand partner guard as
580
+ `getPartnerContributionPages`, so it requires a `token` client; Brand Agent
581
+ `agentSession` and `agentAccess` credentials are rejected. It is the
582
+ partner-facing feed only — brand-admin contribution and refund administration
583
+ operations are not part of the partner surface.
584
+
585
+ Supported filters:
586
+
587
+ - `contributionPageUid`: restrict to one page owned by the brand (a page from
588
+ another brand returns a `Contribution page not found` error).
589
+ - `status`: `SUCCEEDED`, `PENDING`, `PROCESSING`, `REVIEW_REQUIRED`, `FAILED`,
590
+ or `EXPIRED`; omit for all statuses. Only `SUCCEEDED` is confirmed money.
591
+ - `search`: case-insensitive match over donor name and email.
592
+ - `cursor` and `take` (1–100, default 25): use `nextCursor` while `hasNextPage` is true.
593
+
594
+ ```typescript
595
+ const { getPartnerContributions } = await itm.query({
596
+ getPartnerContributions: {
597
+ __args: {
598
+ contributionPageUid: page.uid,
599
+ status: "SUCCEEDED",
600
+ take: 50,
601
+ cursor: nextCursor,
602
+ },
603
+ items: {
604
+ uid: true,
605
+ donorName: true,
606
+ note: true, // null when the contributor left no note
607
+ amountCents: true,
608
+ currencyCode: true,
609
+ paidAt: true,
610
+ createdAt: true,
611
+ },
612
+ totalCount: true,
613
+ hasNextPage: true,
614
+ nextCursor: true,
615
+ },
616
+ });
617
+
618
+ // Rolling feed: items arrive newest first; totalCount is the number of
619
+ // matching contributions (not distinct donors) for the filter.
620
+ ```
621
+
622
+ Refunds do not change `status`: a fully refunded contribution stays
623
+ `SUCCEEDED` with `refundedAmountCents` equal to `amountCents`. For a public
624
+ wall or counter, also skip rows where `refundedAmountCents >= amountCents`.
625
+ `donorName` and `note` are unmoderated contributor input; escape them and apply
626
+ your own moderation before rendering publicly. `donorEmail` is available for
627
+ reconciliation but is personal data; do not render it on public surfaces.
628
+ Amounts are ITM cents (major units × 100, also for zero-decimal currencies) —
629
+ see [Money Amounts](#money-amounts).
630
+
518
631
  ### `getPartnerMoment`
519
632
 
520
633
  Get a single moment by slug.
@@ -539,6 +652,11 @@ const { getPartnerMoment } = await itm.query({
539
652
  city: true,
540
653
  postalCode: true,
541
654
  },
655
+ publicVenuePreview: {
656
+ city: true,
657
+ country: true,
658
+ countryCode: true,
659
+ },
542
660
  ticketTiers: {
543
661
  name: true,
544
662
  price: true,
@@ -548,6 +666,15 @@ const { getPartnerMoment } = await itm.query({
548
666
  });
549
667
  ```
550
668
 
669
+ `Moment.venue` returns full venue details and is nullable. For public location
670
+ text, select `Moment.publicVenuePreview`: it returns `city`, a display `country`
671
+ label, and nullable `countryCode`, without the venue name or street address. It
672
+ remains available when the venue is hidden until RSVP, and returns `null` when
673
+ no venue is set. The API field is named `publicVenuePreview`.
674
+
675
+ The same selection works wherever the API returns a `Moment`, including
676
+ `getPartnerMomentsForBrand.moments` and `Ticket.moment`.
677
+
551
678
  ### `getPublicMomentLineups`
552
679
 
553
680
  Get approved public lineup entries for a moment. Use `getPartnerMoment` first if you only have the moment slug; this query takes the moment `uid`.
@@ -752,13 +879,20 @@ Supported filters include `ticketTierUids`, `search`, `isActive`, `isPrivate`,
752
879
 
753
880
  ### `getPartnerTicketsForMoment`
754
881
 
755
- Get paginated tickets for a moment.
882
+ Get confirmed tickets for a moment owned by your partner token's brand. The
883
+ `user` filter matches the current ITM account holding the ticket, rather than
884
+ guest contact details entered at checkout. Provide exactly one of `userUid`,
885
+ `email`, or `phoneNumber`. An unknown account returns an empty ticket page.
886
+
887
+ `take` defaults to 50 and is capped at 100. Continue with `nextCursor` while
888
+ `hasNextPage` is true to retrieve all tickets.
756
889
 
757
890
  ```typescript
758
891
  const { getPartnerTicketsForMoment } = await itm.query({
759
892
  getPartnerTicketsForMoment: {
760
893
  __args: {
761
894
  momentSlug: "my-event-slug",
895
+ user: { userUid: "customer-uid" }, // optional: exactly one of userUid, email, phoneNumber
762
896
  checkedIn: true, // optional: filter by check-in status
763
897
  take: 50,
764
898
  },
@@ -766,6 +900,7 @@ const { getPartnerTicketsForMoment } = await itm.query({
766
900
  uid: true,
767
901
  price: true,
768
902
  redeemedAt: true,
903
+ encryptedQRCode: true, // render this exact payload as a QR image
769
904
  hasExpired: true,
770
905
  user: { uid: true, name: true, primaryEmail: true },
771
906
  ticketTier: { name: true, price: true },
@@ -778,6 +913,105 @@ const { getPartnerTicketsForMoment } = await itm.query({
778
913
  });
779
914
  ```
780
915
 
916
+ #### Displaying a purchased ITM ticket in a web or mobile app
917
+
918
+ `Ticket.encryptedQRCode` is the exact opaque payload used by ITM scanners. It is
919
+ a string to encode as a QR image, not an image URL. Render it unchanged: do not
920
+ decrypt it, wrap it in JSON, replace it with the ticket UID, or turn it into a
921
+ URL. Your app does not need ITM's QR encryption key.
922
+
923
+ Keep the SDK and partner token on your Node server. Authenticate the app user
924
+ and resolve their verified ITM account association on that server, then use
925
+ that account's UID (or verified email/phone) in the `user` filter. Do not trust
926
+ an account identifier supplied by the app client: partner tokens grant access
927
+ to the brand's tickets and do not authenticate individual attendees. The token
928
+ only retrieves tickets for its own brand's moments.
929
+
930
+ For example, install `qrcode` in your server application (`npm install qrcode`,
931
+ plus `npm install -D @types/qrcode` for TypeScript), and generate a PNG data URL:
932
+
933
+ ```typescript
934
+ import QRCode from "qrcode";
935
+ import { createITMPartnerClient } from "@itm-studio/partner-sdk";
936
+
937
+ const itm = createITMPartnerClient({
938
+ token: process.env.ITM_PARTNER_TOKEN!,
939
+ });
940
+
941
+ // Call only after resolving itmUserUid from the authenticated user's verified
942
+ // account association on your server. It must not come from request parameters.
943
+ async function loadMyMomentTickets(
944
+ itmUserUid: string,
945
+ momentSlug: string,
946
+ cursor?: string,
947
+ ) {
948
+ const { getPartnerTicketsForMoment: page } = await itm.query({
949
+ getPartnerTicketsForMoment: {
950
+ __args: {
951
+ momentSlug,
952
+ user: { userUid: itmUserUid },
953
+ take: 50,
954
+ ...(cursor ? { cursor } : {}),
955
+ },
956
+ tickets: {
957
+ uid: true,
958
+ encryptedQRCode: true,
959
+ redeemedAt: true,
960
+ hasExpired: true,
961
+ ticketTier: { name: true },
962
+ moment: {
963
+ name: true,
964
+ startDate: true,
965
+ timezone: true,
966
+ publicVenuePreview: { city: true, country: true, countryCode: true },
967
+ },
968
+ },
969
+ hasNextPage: true,
970
+ nextCursor: true,
971
+ },
972
+ });
973
+
974
+ return {
975
+ tickets: await Promise.all(page.tickets.map(async (ticket) => ({
976
+ uid: ticket.uid,
977
+ ticketTierName: ticket.ticketTier.name,
978
+ moment: ticket.moment,
979
+ redeemedAt: ticket.redeemedAt,
980
+ hasExpired: ticket.hasExpired,
981
+ qrCodeImageUrl: await QRCode.toDataURL(ticket.encryptedQRCode, {
982
+ errorCorrectionLevel: "H",
983
+ margin: 4,
984
+ width: 300,
985
+ }),
986
+ }))),
987
+ hasNextPage: page.hasNextPage,
988
+ nextCursor: page.nextCursor,
989
+ };
990
+ }
991
+ ```
992
+
993
+ Return this page only to the authenticated owner, with `Cache-Control: private,
994
+ no-store`. A web app can display `ticket.qrCodeImageUrl` as an image:
995
+
996
+ ```tsx
997
+ <img src={ticket.qrCodeImageUrl} alt="ITM ticket QR code" width={300} height={300} />
998
+ ```
999
+
1000
+ A mobile app can display the same PNG data URL using its image component.
1001
+ Alternatively, return `encryptedQRCode` only to the authenticated owner and
1002
+ pass it unchanged to the app's QR component. Preserve a square image, black
1003
+ modules on a white background, and the white margin; avoid cropping or overlays.
1004
+
1005
+ Fetch fresh ticket data whenever the ticket screen opens or regains focus.
1006
+ Transfers can change the holder and rotate the QR payload, and cancelled or
1007
+ refunded tickets no longer appear in this confirmed-ticket query. Avoid shared
1008
+ caches and application logs containing ticket payloads or images.
1009
+
1010
+ Displaying a QR is separate from admission validation. ITM's scanner checks
1011
+ the current ticket and its applicable admission rules at scan time. Display
1012
+ `redeemedAt` and `hasExpired` as ticket metadata; they are not a substitute for
1013
+ scanner validation, especially for tickets with multiple admissions.
1014
+
781
1015
  ### `getPartnerCustomersForMoment`
782
1016
 
783
1017
  Get paginated customers (users with their tickets) for a moment.
@@ -846,6 +1080,117 @@ pages and optional exact counts can be up to 30 seconds old. Consent and
846
1080
  suppression fields reflect ITM's current brand-scoped records but do not replace
847
1081
  your legal-purpose and communications-compliance checks.
848
1082
 
1083
+ ### Membership tiers and members
1084
+
1085
+ `getPartnerMembershipTiers` and `getPartnerMembers` use your partner token to
1086
+ select the brand; neither accepts a brand ID. Both queries use optional `input`
1087
+ with `cursor` and `take` (default 50, maximum 100), returning `totalCount`,
1088
+ `hasNextPage`, and `nextCursor`. These operations require a partner token;
1089
+ adding them to the SDK does not grant access to agent sessions.
1090
+
1091
+ #### `getPartnerMembershipTiers`: fetch every tier
1092
+
1093
+ Unfiltered results include inactive and private tiers, but exclude soft-deleted
1094
+ tiers. Optional filters are `search` (name or slug), `isActive`, `isPublic`, and
1095
+ `passTypes`. Tiers are ordered by `sortOrder`, then creation order.
1096
+
1097
+ ```typescript
1098
+ import type { GetPartnerMembershipTiersInput } from "@itm-studio/partner-sdk";
1099
+
1100
+ let tierCursor: string | null = null;
1101
+ do {
1102
+ const tierInput: GetPartnerMembershipTiersInput = { cursor: tierCursor, take: 100 };
1103
+ const { getPartnerMembershipTiers: page } = await itm.query({
1104
+ getPartnerMembershipTiers: {
1105
+ __args: { input: tierInput },
1106
+ membershipTiers: {
1107
+ uid: true, name: true, slug: true, passType: true,
1108
+ currencyCode: true, price: true, yearlyPrice: true, oneTimePrice: true,
1109
+ isActive: true, isPublic: true, activeMemberCount: true,
1110
+ },
1111
+ totalCount: true, hasNextPage: true, nextCursor: true,
1112
+ },
1113
+ });
1114
+ // Process or persist page.membershipTiers on your server.
1115
+ if (!page.hasNextPage) break;
1116
+ if (!page.nextCursor || page.nextCursor === tierCursor) {
1117
+ throw new Error("Missing or repeated membership tier cursor");
1118
+ }
1119
+ tierCursor = page.nextCursor;
1120
+ } while (true);
1121
+ ```
1122
+
1123
+ Tier `price`, `yearlyPrice`, and `oneTimePrice` are stored in hundredths of the
1124
+ currency's major unit, including JPY: divide by 100 to display a major-unit
1125
+ amount. These are ITM prices, not Stripe minor-unit amounts.
1126
+ `activeMemberCount` counts ACTIVE and PAST_DUE membership rows, excluding deleted
1127
+ users; it is not a count of distinct people.
1128
+
1129
+ #### `getPartnerMembers`: fetch brand-wide or selected tiers
1130
+
1131
+ Each `members` row represents one membership, identified by its `uid`, with
1132
+ `customer` and `membershipTier` details. A customer may appear more than once;
1133
+ use `customer.uid` to group people when needed. It matches the customer UID
1134
+ returned by `getPartnerAudience`. Members are ordered newest first.
1135
+
1136
+ ```typescript
1137
+ import type { GetPartnerMembersInput } from "@itm-studio/partner-sdk";
1138
+
1139
+ // Leave empty for the entire brand. To select tiers, use:
1140
+ // { membershipTierUids: ["tier-uid"] }
1141
+ const memberFilters: GetPartnerMembersInput = {};
1142
+ let memberCursor: string | null = null;
1143
+ do {
1144
+ const memberInput: GetPartnerMembersInput = { ...memberFilters, cursor: memberCursor, take: 100 };
1145
+ const { getPartnerMembers: page } = await itm.query({
1146
+ getPartnerMembers: {
1147
+ __args: { input: memberInput },
1148
+ members: {
1149
+ uid: true, status: true, source: true, membershipNumber: true,
1150
+ quantity: true, purchaseType: true, issuedAt: true,
1151
+ currentPeriodStart: true, currentPeriodEnd: true,
1152
+ cancelAtPeriodEnd: true, remainingUses: true,
1153
+ customer: { uid: true, name: true, email: true, emailVerified: true, phoneNumber: true },
1154
+ membershipTier: { uid: true, name: true, slug: true, passType: true },
1155
+ },
1156
+ totalCount: true, hasNextPage: true, nextCursor: true,
1157
+ },
1158
+ });
1159
+ // Process or persist page.members on your server.
1160
+ if (!page.hasNextPage) break;
1161
+ if (!page.nextCursor || page.nextCursor === memberCursor) {
1162
+ throw new Error("Missing or repeated membership cursor");
1163
+ }
1164
+ memberCursor = page.nextCursor;
1165
+ } while (true);
1166
+ ```
1167
+
1168
+ The default includes ACTIVE, PAST_DUE, CANCELLED, EXPIRED, and REFUNDED.
1169
+ PENDING represents unpaid checkout reservations and is excluded unless requested
1170
+ explicitly. To include every status, set `memberFilters.statuses` to
1171
+ `["ACTIVE", "PAST_DUE", "CANCELLED", "EXPIRED", "REFUNDED", "PENDING"]`.
1172
+ An empty or null statuses list also uses the default.
1173
+
1174
+ Additional filters include `search` (name, email, phone, or exact membership
1175
+ number), `sources`, `customerUids`, `cancelAtPeriodEnd`, `createdAtFrom`,
1176
+ `createdAtBefore`, `currentPeriodEndFrom`, and `currentPeriodEndBefore`.
1177
+ `From` dates are inclusive and `Before` dates exclusive. Keep filters identical
1178
+ across pages. Tier and customer UID lists accept at most 100 distinct values;
1179
+ search accepts at most 100 characters. Foreign and nonexistent tier UIDs return
1180
+ the same error.
1181
+
1182
+ Deleted users are excluded. Memberships on soft-deleted tiers remain available,
1183
+ so fetch the brand-wide member listing directly rather than assembling it by
1184
+ looping over listed tiers. Pagination reads live data without a snapshot
1185
+ guarantee: changes during a walk, especially tier reordering, can omit or repeat
1186
+ rows. `remainingUses: null` means unlimited; a null `currentPeriodEnd` alone does
1187
+ not establish whether a membership has no expiry.
1188
+
1189
+ The partner responses expose the allowlisted customer, membership, and tier
1190
+ fields, excluding QR tokens, Stripe/payment identifiers, guest information, and
1191
+ form responses. Both endpoints are limited to 120 requests per minute per
1192
+ partner token.
1193
+
849
1194
  ### `getPartnerMomentCollection`
850
1195
 
851
1196
  Get a moment collection by slug. Collections group related moments together (e.g. a summer series, a festival lineup). Returns the collection with its moments and links.
@@ -1913,3 +2258,38 @@ Each requested revision gets a new reservation and brand media asset.
1913
2258
  Deploy the backend reservation migration and schema before publishing SDK 0.3.3,
1914
2259
  then release the Eve and Backstage consumers. These operations do not call an
1915
2260
  image provider or consume notification credits.
2261
+
2262
+
2263
+ ### Record an off-site contribution
2264
+
2265
+ Partner tokens and brand-agent sessions can use `getPartnerContributionPages` to
2266
+ choose a page, then log money already received outside ITM. The backend derives
2267
+ brand scope from authentication. Name and amount are required; email, phone (with
2268
+ country code), and note are optional. This creates no charge, refund capability,
2269
+ new account, or automatic receipt. Paused pages accept manual records; archived
2270
+ pages reject new records. Amounts use ITM cents (major units × 100, including JPY).
2271
+
2272
+ ```ts
2273
+ const result = await itm.mutation({
2274
+ createPartnerManualContribution: {
2275
+ __args: { input: {
2276
+ contributionPageUid: pageUid,
2277
+ currencyCode: pageCurrencyCode, // Prevent stale page currency from changing the amount.
2278
+ requestUid, // Generate once and retain unchanged across retries.
2279
+ name: 'Alex',
2280
+ amountCents: 2500,
2281
+ } },
2282
+ uid: true,
2283
+ source: true, // OFF_SITE
2284
+ status: true, // SUCCEEDED
2285
+ },
2286
+ });
2287
+ ```
2288
+
2289
+ `ContributionAdmin.donorEmail` is nullable for name-only manual entries.
2290
+ Set `sendReceipt: true` with an `email` to request a receipt. The backend chooses
2291
+ and freezes `manualReceiptType` (`REGULAR` or `DONATION`) from the brand's current
2292
+ receipt eligibility. Omit it for no receipt. `userUid` is populated when entered
2293
+ email or phone matches a current verified ITM identity; unmatched contacts do not
2294
+ create an account, and conflicting matches are rejected. `source` distinguishes
2295
+ `STRIPE`, `OFF_SITE`, and in-person `POS` independently of payment status.