@birtalanrobert/commerce 1.0.0 → 3.0.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.
Files changed (46) hide show
  1. package/README.md +9 -1
  2. package/dist/index.d.ts +8 -6
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +20 -7
  5. package/dist/index.js.map +1 -1
  6. package/dist/migrations/1790000000000-AddSavedCardsAndKind.d.ts +30 -0
  7. package/dist/migrations/1790000000000-AddSavedCardsAndKind.d.ts.map +1 -0
  8. package/dist/migrations/1790000000000-AddSavedCardsAndKind.js +89 -0
  9. package/dist/migrations/1790000000000-AddSavedCardsAndKind.js.map +1 -0
  10. package/dist/nestjs/commerce.service.d.ts +65 -1
  11. package/dist/nestjs/commerce.service.d.ts.map +1 -1
  12. package/dist/nestjs/commerce.service.js +98 -2
  13. package/dist/nestjs/commerce.service.js.map +1 -1
  14. package/dist/nestjs/index.d.ts +6 -3
  15. package/dist/nestjs/index.d.ts.map +1 -1
  16. package/dist/nestjs/index.js +9 -3
  17. package/dist/nestjs/index.js.map +1 -1
  18. package/dist/nestjs/payment.entity.d.ts +11 -19
  19. package/dist/nestjs/payment.entity.d.ts.map +1 -1
  20. package/dist/nestjs/payment.entity.js +13 -0
  21. package/dist/nestjs/payment.entity.js.map +1 -1
  22. package/dist/nestjs/saved-card.entity.d.ts +42 -0
  23. package/dist/nestjs/saved-card.entity.d.ts.map +1 -0
  24. package/dist/nestjs/saved-card.entity.js +109 -0
  25. package/dist/nestjs/saved-card.entity.js.map +1 -0
  26. package/dist/payments.d.ts +54 -0
  27. package/dist/payments.d.ts.map +1 -0
  28. package/dist/payments.js +30 -0
  29. package/dist/payments.js.map +1 -0
  30. package/dist/providers/port.d.ts +68 -0
  31. package/dist/providers/port.d.ts.map +1 -1
  32. package/dist/providers/stripe.d.ts +12 -1
  33. package/dist/providers/stripe.d.ts.map +1 -1
  34. package/dist/providers/stripe.js +71 -1
  35. package/dist/providers/stripe.js.map +1 -1
  36. package/package.json +16 -3
  37. package/src/index.ts +27 -5
  38. package/src/migrations/1790000000000-AddSavedCardsAndKind.ts +92 -0
  39. package/src/nestjs/commerce.service.ts +170 -3
  40. package/src/nestjs/index.ts +15 -3
  41. package/src/nestjs/payment.entity.ts +17 -25
  42. package/src/nestjs/saved-card.entity.ts +69 -0
  43. package/src/payments.ts +67 -0
  44. package/src/providers/port.ts +74 -0
  45. package/src/providers/stripe.ts +90 -1
  46. package/stripe/package.json +5 -0
@@ -28,6 +28,20 @@ export interface OnboardingLink {
28
28
  export interface ChargeRequest {
29
29
  /** The business being paid, as the provider knows it. */
30
30
  readonly account: string;
31
+ /**
32
+ * The customer, where one is being charged again rather than for the first
33
+ * time. Held on *our* account rather than the business's, because that is
34
+ * where a saved card lives under a destination charge.
35
+ */
36
+ readonly customer?: string;
37
+ /**
38
+ * A card already saved, to charge without anybody present.
39
+ *
40
+ * The whole point of storing one: a no-show fee is decided days later, and
41
+ * asking somebody who did not turn up to enter a card is a conversation that
42
+ * does not happen.
43
+ */
44
+ readonly paymentMethod?: string;
31
45
  readonly amount: number;
32
46
  readonly currency: string;
33
47
  /** Our cut, taken on top rather than out of the business's money. */
@@ -57,10 +71,48 @@ export interface ChargeResult {
57
71
  * failure and must not be handled as one.
58
72
  */
59
73
  readonly redirectUrl?: string;
74
+ /**
75
+ * What the browser needs to finish paying, when the customer is present.
76
+ *
77
+ * A charge created on the server has no card attached to it yet — the card is
78
+ * entered in a browser, against the provider's own script, so that the number
79
+ * never reaches us. Without this the payment can be created and never paid,
80
+ * which is the state a customer reads as "it took my booking and lost my
81
+ * money".
82
+ */
83
+ readonly clientSecret?: string;
60
84
  readonly instrument?: string;
61
85
  readonly detail?: string;
62
86
  }
63
87
 
88
+ /** A card being stored for later, rather than charged now. */
89
+ export interface SaveCardRequest {
90
+ /** An existing customer to attach it to, where the person already has one. */
91
+ readonly customer?: string;
92
+ /** What it is being saved for, carried through for matching a webhook back. */
93
+ readonly subject: string;
94
+ readonly reference: string;
95
+ }
96
+
97
+ export interface SaveCardResult {
98
+ /** The customer the card will hang off, created here if there was none. */
99
+ readonly customer: string;
100
+ /** The provider's handle on this attempt, to read the result back from. */
101
+ readonly externalId: string;
102
+ /** What the browser confirms against. */
103
+ readonly clientSecret: string;
104
+ }
105
+
106
+ /** A card that was actually stored, read back after the browser confirmed it. */
107
+ export interface StoredCard {
108
+ readonly customer: string;
109
+ readonly paymentMethod: string;
110
+ readonly brand?: string;
111
+ readonly last4?: string;
112
+ readonly expiryMonth?: number;
113
+ readonly expiryYear?: number;
114
+ }
115
+
64
116
  export interface RefundRequest {
65
117
  readonly externalId: string;
66
118
  readonly amount: number;
@@ -95,6 +147,28 @@ export interface PaymentProvider {
95
147
  /** Releases a hold without taking anything. */
96
148
  release(externalId: string): Promise<void>;
97
149
 
150
+ /**
151
+ * Starts storing a card without charging it.
152
+ *
153
+ * The strongest thing a business can do about no-shows short of taking money:
154
+ * nothing leaves the customer's account, and the card is there if a fee is
155
+ * later decided on. A hold is not a substitute — providers expire one within
156
+ * days, and an appointment is usually further away than that.
157
+ */
158
+ saveCard(request: SaveCardRequest): Promise<SaveCardResult>;
159
+
160
+ /**
161
+ * What was actually stored, once the browser says it finished.
162
+ *
163
+ * Read back from the provider rather than believed from the browser: what a
164
+ * page reports is what a page was told to report, and a saved card is
165
+ * something a business will later charge money against.
166
+ */
167
+ storedCard(externalId: string): Promise<StoredCard | undefined>;
168
+
169
+ /** Forgets a stored card, at the customer's request or the business's. */
170
+ forgetCard(paymentMethod: string): Promise<void>;
171
+
98
172
  refund(request: RefundRequest): Promise<{ externalId: string }>;
99
173
 
100
174
  /**
@@ -7,6 +7,9 @@ import type {
7
7
  ProviderAccount,
8
8
  ProviderEvent,
9
9
  RefundRequest,
10
+ SaveCardRequest,
11
+ SaveCardResult,
12
+ StoredCard,
10
13
  } from './port';
11
14
 
12
15
  export interface StripeConnectOptions {
@@ -95,10 +98,31 @@ export class StripeConnect implements PaymentProvider {
95
98
  ...(request.applicationFee > 0 ? { application_fee_amount: request.applicationFee } : {}),
96
99
  capture_method: request.capture ? 'automatic' : 'manual',
97
100
  ...(request.description ? { description: request.description } : {}),
101
+ ...(request.customer ? { customer: request.customer } : {}),
102
+ /*
103
+ * A stored card is confirmed here and now, with nobody present.
104
+ *
105
+ * `off_session` is what tells the network the customer is not sitting
106
+ * there — it is also what makes the bank decline rather than ask for
107
+ * a code it has nobody to ask, which is the honest outcome for a fee
108
+ * charged three days after an appointment.
109
+ */
110
+ ...(request.paymentMethod
111
+ ? {
112
+ payment_method: request.paymentMethod,
113
+ confirm: true,
114
+ off_session: true,
115
+ }
116
+ : {}),
98
117
  // Carried through so a webhook can be matched back to what it paid
99
118
  // for without a lookup table of our own.
100
119
  metadata: { subject: request.subject },
101
- automatic_payment_methods: { enabled: true },
120
+ /*
121
+ * Only where a card still has to be entered. Offering a redirect
122
+ * method to a charge that is already confirmed against a stored card
123
+ * is a contradiction the provider rejects.
124
+ */
125
+ ...(request.paymentMethod ? {} : { automatic_payment_methods: { enabled: true } }),
102
126
  },
103
127
  // The provider's own idempotency, so a retried request — a timeout, a
104
128
  // double submit — does not charge somebody twice.
@@ -128,6 +152,70 @@ export class StripeConnect implements PaymentProvider {
128
152
  await this.stripe.paymentIntents.cancel(externalId);
129
153
  }
130
154
 
155
+ /**
156
+ * Starts storing a card without charging it.
157
+ *
158
+ * The customer is created on **our** account rather than the business's. That
159
+ * is not a preference: under a destination charge the money lands on the
160
+ * business's account while the card is ours to charge, and a customer created
161
+ * on the business's account cannot be used from here at all.
162
+ */
163
+ async saveCard(request: SaveCardRequest): Promise<SaveCardResult> {
164
+ const customer =
165
+ request.customer ??
166
+ (
167
+ await this.stripe.customers.create(
168
+ { metadata: { subject: request.subject } },
169
+ { idempotencyKey: `customer-${request.reference}` },
170
+ )
171
+ ).id;
172
+
173
+ const intent = await this.stripe.setupIntents.create(
174
+ {
175
+ customer,
176
+ usage: 'off_session',
177
+ payment_method_types: ['card'],
178
+ metadata: { subject: request.subject },
179
+ },
180
+ { idempotencyKey: request.reference },
181
+ );
182
+
183
+ return {
184
+ customer,
185
+ externalId: intent.id,
186
+ // Non-null because a setup intent is created precisely to be confirmed.
187
+ clientSecret: intent.client_secret ?? '',
188
+ };
189
+ }
190
+
191
+ async storedCard(externalId: string): Promise<StoredCard | undefined> {
192
+ const intent = await this.stripe.setupIntents.retrieve(externalId, {
193
+ expand: ['payment_method'],
194
+ });
195
+
196
+ const method = intent.payment_method;
197
+ if (!method || typeof method === 'string' || !intent.customer) return undefined;
198
+
199
+ const customer = typeof intent.customer === 'string' ? intent.customer : intent.customer.id;
200
+
201
+ return {
202
+ customer,
203
+ paymentMethod: method.id,
204
+ ...(method.card
205
+ ? {
206
+ brand: method.card.brand,
207
+ last4: method.card.last4,
208
+ expiryMonth: method.card.exp_month,
209
+ expiryYear: method.card.exp_year,
210
+ }
211
+ : {}),
212
+ };
213
+ }
214
+
215
+ async forgetCard(paymentMethod: string): Promise<void> {
216
+ await this.stripe.paymentMethods.detach(paymentMethod);
217
+ }
218
+
131
219
  async refund(request: RefundRequest): Promise<{ externalId: string }> {
132
220
  const refund = await this.stripe.refunds.create(
133
221
  {
@@ -205,6 +293,7 @@ function interpretIntent(intent: Stripe.PaymentIntent): ChargeResult {
205
293
  return {
206
294
  externalId: intent.id,
207
295
  state,
296
+ ...(intent.client_secret ? { clientSecret: intent.client_secret } : {}),
208
297
  ...(intent.next_action?.redirect_to_url?.url
209
298
  ? { redirectUrl: intent.next_action.redirect_to_url.url }
210
299
  : {}),
@@ -0,0 +1,5 @@
1
+ {
2
+ "main": "../dist/providers/stripe.js",
3
+ "types": "../dist/providers/stripe.d.ts",
4
+ "sideEffects": false
5
+ }